diff --git a/.gitignore b/.gitignore index f176e0e818..d1e6ebaca7 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ node_modules/ # source; not tracked. *.vfs *.vfs.zst +*.vfs.zst.meta.json # OS noise .DS_Store diff --git a/MANIFEST b/MANIFEST index 27d0251ab6..5040fa93c7 100644 --- a/MANIFEST +++ b/MANIFEST @@ -8,9 +8,9 @@ # The mkrootfs builder refuses to silently drop on-disk files that # aren't declared here, so every images/rootfs/* addition needs a line below. # -# Ownership defaults to 0:0 (root). uid/gid are honest once the -# kernel's synthetic /etc interception is removed in PR 4/5 — user -# programs will see these exact values via stat(). +# Ownership defaults to 0:0 (root). User programs observe these exact values +# through the mounted VFS image; the kernel's dynamic `/etc/mtab` exception +# does not shadow any manifest-owned path. # # The /dev tree is intentionally absent: the kernel intercepts # /dev/{null,zero,full,random,urandom,tty,console,fb0,stdin,stdout, @@ -32,6 +32,8 @@ /usr/bin d 0755 0 0 /usr/share d 0755 0 0 /usr/share/misc d 0755 0 0 +/etc/ssl d 0755 0 0 +/etc/ssl/certs d 0755 0 0 # ── User home ────────────────────────────────────────────────────── /home/user d 0755 1000 1000 @@ -51,11 +53,18 @@ /etc/os-release f 0644 0 0 /etc/profile f 0644 0 0 /etc/motd f 0644 0 0 -# services matches the ad-hoc table in -# apps/browser-demos/lib/browser-kernel.ts byte-for-byte so the cutover -# is invisible to getservbyname/getservbyport callers. +# The rootfs file is the authoritative services database. Rootfs-derived +# images inherit it rather than rebuilding or synthesizing a second table. /etc/services f 0644 0 0 +# OpenSSL was built with --openssldir=/etc/ssl. Static policy and public trust +# roots are ordinary image state, not kernel-synthesized files. The browser +# host may replace ca-certificates.crt with its per-session MITM root while +# leaving OpenSSL's compiled-in cert.pem path unchanged. +/etc/ssl/openssl.cnf f 0644 0 0 +/etc/ssl/cert.pem f 0644 0 0 +/etc/ssl/certs/ca-certificates.crt f 0644 0 0 src=images/rootfs/etc/ssl/cert.pem + # ── Shells ───────────────────────────────────────────────────────── # /bin/sh is provided by the generated package manifest as a symlink to # /usr/bin/dash. The rootfs/bin/sh file is a legacy source-tree stub and is diff --git a/abi/snapshot.json b/abi/snapshot.json index 91b56f048d..2d2c913d6e 100644 --- a/abi/snapshot.json +++ b/abi/snapshot.json @@ -1,5 +1,5 @@ { - "abi_version": 18, + "abi_version": 39, "channel_buffers": { "data_offset": 72, "data_size": 65536, @@ -113,7 +113,7 @@ }, "host_adapter": { "manifest": { - "abi_version": 18, + "abi_version": 39, "channel_data_offset": 72, "channel_data_size": 65536, "channel_header_size": 72, @@ -197,13 +197,19 @@ "kernel_create_process", "kernel_create_process_with_stdio", "kernel_get_parent_pid", + "kernel_get_process_exit_signal", + "kernel_get_process_state", "kernel_handle_channel", + "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", "kernel_mark_process_signaled", + "kernel_pipe_has_readers", + "kernel_posix_timer_fire", + "kernel_prepare_write_operation", "kernel_reap_exited_child", "kernel_remove_process", - "kernel_wait4_poll" + "kernel_wait_child_poll" ], "required_worker_features": 7, "version": 1, @@ -335,6 +341,11 @@ "name": "kernel_clear_fork_exec", "signature": "() -> (i32)" }, + { + "kind": "func", + "name": "kernel_clear_process_metadata", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_clock_getres", @@ -455,11 +466,21 @@ "name": "kernel_eventfd2", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_exec_prepare", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_exec_setup", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_exec_setup_for_thread", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_execve", @@ -515,11 +536,26 @@ "name": "kernel_fcntl_lock", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_fd_is_open", + "signature": "(i32,i32) -> (i32)" + }, + { + "kind": "func", + "name": "kernel_fd_supports_mmap_writeback", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_fdatasync", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_find_listener_fd_by_accept_wake", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_flock", @@ -538,7 +574,7 @@ { "kind": "func", "name": "kernel_fpathconf", - "signature": "(i32,i32) -> (i64)" + "signature": "(i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -655,11 +691,21 @@ "name": "kernel_get_pipe_ofds", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_get_process_exit_signal", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_get_process_exit_status", "signature": "(i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_get_process_state", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_get_robust_list", @@ -810,6 +856,11 @@ "name": "kernel_handle_channel", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_has_sa_nocldstop", + "signature": "(i32) -> (i32)" + }, { "kind": "func", "name": "kernel_has_sa_nocldwait", @@ -1018,7 +1069,7 @@ { "kind": "func", "name": "kernel_pathconf", - "signature": "(i32,i32,i32) -> (i64)" + "signature": "(i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -1050,6 +1101,11 @@ "name": "kernel_pipe_close_write", "signature": "(i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_pipe_has_readers", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pipe_is_read_open", @@ -1075,6 +1131,11 @@ "name": "kernel_poll", "signature": "(i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_posix_timer_fire", + "signature": "(i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_posix_timer_interval_fire", @@ -1100,6 +1161,11 @@ "name": "kernel_preadv", "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, + { + "kind": "func", + "name": "kernel_prepare_write_operation", + "signature": "(i32,i32,i64,i32,i32) -> (i64)" + }, { "kind": "func", "name": "kernel_pselect6", @@ -1130,6 +1196,11 @@ "name": "kernel_push_argv", "signature": "(i32,i32) -> ()" }, + { + "kind": "func", + "name": "kernel_push_process_metadata_entry", + "signature": "(i32,i32,i32,i32) -> (i32)" + }, { "kind": "func", "name": "kernel_pwrite", @@ -1632,8 +1703,8 @@ }, { "kind": "func", - "name": "kernel_wait4_poll", - "signature": "(i32,i32,i32) -> (i32)" + "name": "kernel_wait_child_poll", + "signature": "(i32,i32,i32,i32,i32) -> (i32)" }, { "kind": "func", @@ -2006,6 +2077,36 @@ ], "size": 32 }, + "KernelWaitResult": { + "fields": [ + { + "name": "wait_status", + "offset": 0, + "span": 4 + }, + { + "name": "si_code", + "offset": 4, + "span": 4 + }, + { + "name": "si_status", + "offset": 8, + "span": 4 + }, + { + "name": "child_uid", + "offset": 12, + "span": 4 + }, + { + "name": "rusage", + "offset": 16, + "span": 144 + } + ], + "size": 160 + }, "WasmDirent": { "fields": [ { @@ -2086,6 +2187,101 @@ ], "size": 8 }, + "WasmRusageWire": { + "fields": [ + { + "name": "ru_utime_sec", + "offset": 0, + "span": 8 + }, + { + "name": "ru_utime_usec", + "offset": 8, + "span": 8 + }, + { + "name": "ru_stime_sec", + "offset": 16, + "span": 8 + }, + { + "name": "ru_stime_usec", + "offset": 24, + "span": 8 + }, + { + "name": "ru_maxrss", + "offset": 32, + "span": 8 + }, + { + "name": "ru_ixrss", + "offset": 40, + "span": 8 + }, + { + "name": "ru_idrss", + "offset": 48, + "span": 8 + }, + { + "name": "ru_isrss", + "offset": 56, + "span": 8 + }, + { + "name": "ru_minflt", + "offset": 64, + "span": 8 + }, + { + "name": "ru_majflt", + "offset": 72, + "span": 8 + }, + { + "name": "ru_nswap", + "offset": 80, + "span": 8 + }, + { + "name": "ru_inblock", + "offset": 88, + "span": 8 + }, + { + "name": "ru_oublock", + "offset": 96, + "span": 8 + }, + { + "name": "ru_msgsnd", + "offset": 104, + "span": 8 + }, + { + "name": "ru_msgrcv", + "offset": 112, + "span": 8 + }, + { + "name": "ru_nsignals", + "offset": 120, + "span": 8 + }, + { + "name": "ru_nvcsw", + "offset": 128, + "span": 8 + }, + { + "name": "ru_nivcsw", + "offset": 136, + "span": 8 + } + ], + "size": 144 + }, "WasmStat": { "fields": [ { @@ -2937,6 +3133,32 @@ "size": 16 } }, + "pathconf_names": { + "ALLOC_SIZE_MIN": 18, + "ASYNC_IO": 10, + "CHOWN_RESTRICTED": 6, + "FALLOC": 21, + "FILESIZEBITS": 13, + "LINK_MAX": 0, + "MAX_CANON": 1, + "MAX_INPUT": 2, + "NAME_MAX": 3, + "NO_TRUNC": 7, + "PATH_MAX": 4, + "PIPE_BUF": 5, + "POSIX2_SYMLINKS": 20, + "PRIO_IO": 11, + "REC_INCR_XFER_SIZE": 14, + "REC_MAX_XFER_SIZE": 15, + "REC_MIN_XFER_SIZE": 16, + "REC_XFER_ALIGN": 17, + "SOCK_MAXBUF": 12, + "SYMLINK_MAX": 19, + "SYNC_IO": 9, + "TEXTDOMAIN_MAX": 22, + "TIMESTAMP_RESOLUTION": 23, + "VDISABLE": 8 + }, "process_expected_globals": [ "__channel_base", "__tls_base" @@ -3067,6 +3289,7 @@ { "argIndex": 1, "direction": "out", + "required": true, "size": { "size": 144, "type": "fixed" @@ -3117,6 +3340,35 @@ } } ], + "112": [ + { + "argIndex": 0, + "direction": "in", + "size": { + "type": "cstring" + } + }, + { + "argIndex": 2, + "direction": "out", + "required": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], + "113": [ + { + "argIndex": 2, + "direction": "out", + "required": true, + "size": { + "size": 8, + "type": "fixed" + } + } + ], "114": [ { "argIndex": 1, @@ -3224,6 +3476,7 @@ { "argIndex": 1, "direction": "in", + "nullable": true, "size": { "type": "cstring" } @@ -3358,7 +3611,7 @@ "argIndex": 3, "direction": "out", "size": { - "size": 32, + "size": 144, "type": "fixed" } } @@ -3631,6 +3884,17 @@ } } ], + "238": [ + { + "argIndex": 2, + "direction": "out", + "required": true, + "size": { + "size": 4, + "type": "fixed" + } + } + ], "24": [ { "argIndex": 0, @@ -3731,6 +3995,35 @@ } } ], + "288": [ + { + "argIndex": 2, + "direction": "out", + "required": true, + "size": { + "size": 128, + "type": "fixed" + } + }, + { + "argIndex": 4, + "direction": "out", + "nullable": true, + "size": { + "size": 144, + "type": "fixed" + } + } + ], + "299": [ + { + "argIndex": 0, + "direction": "in", + "size": { + "type": "cstring" + } + } + ], "3": [ { "argIndex": 1, @@ -5078,6 +5371,10 @@ "name": "SchedRrGetInterval", "number": 236 }, + { + "name": "SchedGetaffinity", + "number": 238 + }, { "name": "EpollCreate1", "number": 239 @@ -5142,6 +5439,10 @@ "name": "Pwritev", "number": 296 }, + { + "name": "Lchown", + "number": 299 + }, { "name": "Fallocate", "number": 308 @@ -5262,5 +5563,25 @@ "name": "ThreadCancel", "number": 415 } - ] + ], + "wait_contract": { + "PROCESS_STATE_EXITED": 2, + "PROCESS_STATE_RUNNING": 0, + "PROCESS_STATE_STOPPED": 1, + "WAIT_CLD_CONTINUED": 6, + "WAIT_CLD_EXITED": 1, + "WAIT_CLD_KILLED": 2, + "WAIT_CLD_STOPPED": 5, + "WAIT_EVENT_CONTINUED": 4, + "WAIT_EVENT_EXITED": 1, + "WAIT_EVENT_STOPPED": 2, + "WAIT_WCONTINUED": 8, + "WAIT_WEXITED": 4, + "WAIT_WNOHANG": 1, + "WAIT_WNOWAIT": 16777216, + "WAIT_WSTOPPED": 2, + "WAIT_WUNTRACED": 2, + "WAKE_PROCESS_CONTINUED": 32, + "WAKE_PROCESS_STOPPED": 16 + } } diff --git a/apps/browser-demos/lib/init/rootfs-lazy-files.ts b/apps/browser-demos/lib/init/rootfs-lazy-files.ts new file mode 100644 index 0000000000..42bef7d642 --- /dev/null +++ b/apps/browser-demos/lib/init/rootfs-lazy-files.ts @@ -0,0 +1,146 @@ +import type { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; + +// Keep this module limited to the canonical rootfs package dependency closure. +// Focused consumers such as the PHP PHPT runner must not need every optional +// utility in the interactive shell catalog just to resolve the rootfs entries +// their VFS image actually carries. +import dashWasmUrl from "@binaries/programs/wasm32/dash.wasm?url"; +import bashWasmUrl from "@binaries/programs/wasm32/bash.wasm?url"; +import coreutilsWasmUrl from "@binaries/programs/wasm32/coreutils.wasm?url"; +import gawkWasmUrl from "@binaries/programs/wasm32/gawk.wasm?url"; +import grepWasmUrl from "@binaries/programs/wasm32/grep.wasm?url"; +import sedWasmUrl from "@binaries/programs/wasm32/sed.wasm?url"; +import bcWasmUrl from "@binaries/programs/wasm32/bc.wasm?url"; +import fileWasmUrl from "@binaries/programs/wasm32/file/file.wasm?url"; +import m4WasmUrl from "@binaries/programs/wasm32/m4.wasm?url"; +import makeWasmUrl from "@binaries/programs/wasm32/make.wasm?url"; +import findWasmUrl from "@binaries/programs/wasm32/findutils/find.wasm?url"; +import xargsWasmUrl from "@binaries/programs/wasm32/findutils/xargs.wasm?url"; +import diffWasmUrl from "@binaries/programs/wasm32/diffutils/diff.wasm?url"; +import cmpWasmUrl from "@binaries/programs/wasm32/diffutils/cmp.wasm?url"; +import diff3WasmUrl from "@binaries/programs/wasm32/diffutils/diff3.wasm?url"; +import sdiffWasmUrl from "@binaries/programs/wasm32/diffutils/sdiff.wasm?url"; +import ncursesClearWasmUrl from "@binaries/programs/wasm32/ncurses/clear.wasm?url"; +import ncursesResetWasmUrl from "@binaries/programs/wasm32/ncurses/reset.wasm?url"; +import ncursesTsetWasmUrl from "@binaries/programs/wasm32/ncurses/tset.wasm?url"; +import ncursesTputWasmUrl from "@binaries/programs/wasm32/ncurses/tput.wasm?url"; +import ncursesTabsWasmUrl from "@binaries/programs/wasm32/ncurses/tabs.wasm?url"; +import ncursesTicWasmUrl from "@binaries/programs/wasm32/ncurses/tic.wasm?url"; +import ncursesInfocmpWasmUrl from "@binaries/programs/wasm32/ncurses/infocmp.wasm?url"; +import ncursesToeWasmUrl from "@binaries/programs/wasm32/ncurses/toe.wasm?url"; +import ncursesCaptoinfoWasmUrl from "@binaries/programs/wasm32/ncurses/captoinfo.wasm?url"; +import ncursesInfotocapWasmUrl from "@binaries/programs/wasm32/ncurses/infotocap.wasm?url"; +import posixArWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ar.wasm?url"; +import posixAsaWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/asa.wasm?url"; +import posixCalWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/cal.wasm?url"; +import posixCflowWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/cflow.wasm?url"; +import posixCompressWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/compress.wasm?url"; +import posixCtagsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ctags.wasm?url"; +import posixCxrefWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/cxref.wasm?url"; +import posixEdWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ed.wasm?url"; +import posixExWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ex.wasm?url"; +import posixFuserWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/fuser.wasm?url"; +import posixGencatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gencat.wasm?url"; +import posixGetconfWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/getconf.wasm?url"; +import posixGettextWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gettext.wasm?url"; +import posixIconvWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/iconv.wasm?url"; +import posixIpcrmWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ipcrm.wasm?url"; +import posixIpcsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ipcs.wasm?url"; +import posixLexWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/lex.wasm?url"; +import posixLocaleWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/locale.wasm?url"; +import posixLoggerWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/logger.wasm?url"; +import posixManWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/man.wasm?url"; +import posixMoreWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/more.wasm?url"; +import posixMsgfmtWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/msgfmt.wasm?url"; +import posixNgettextWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ngettext.wasm?url"; +import posixNmWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/nm.wasm?url"; +import posixPatchWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/patch.wasm?url"; +import posixPaxWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/pax.wasm?url"; +import posixPgrepWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/pgrep.wasm?url"; +import posixPsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ps.wasm?url"; +import posixReniceWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/renice.wasm?url"; +import posixStringsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/strings.wasm?url"; +import posixStripWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/strip.wasm?url"; +import posixUncompressWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/uncompress.wasm?url"; +import posixUudecodeWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/uudecode.wasm?url"; +import posixUuencodeWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/uuencode.wasm?url"; +import posixWhatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/what.wasm?url"; +import posixXgettextWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/xgettext.wasm?url"; +import posixYaccWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/yacc.wasm?url"; + +const ROOTFS_LAZY_ASSET_URLS = new Map([ + ["binaries/programs/wasm32/dash.wasm", dashWasmUrl], + ["binaries/programs/wasm32/bash.wasm", bashWasmUrl], + ["binaries/programs/wasm32/coreutils.wasm", coreutilsWasmUrl], + ["binaries/programs/wasm32/gawk.wasm", gawkWasmUrl], + ["binaries/programs/wasm32/grep.wasm", grepWasmUrl], + ["binaries/programs/wasm32/sed.wasm", sedWasmUrl], + ["binaries/programs/wasm32/bc.wasm", bcWasmUrl], + ["binaries/programs/wasm32/file/file.wasm", fileWasmUrl], + ["binaries/programs/wasm32/m4.wasm", m4WasmUrl], + ["binaries/programs/wasm32/make.wasm", makeWasmUrl], + ["binaries/programs/wasm32/findutils/find.wasm", findWasmUrl], + ["binaries/programs/wasm32/findutils/xargs.wasm", xargsWasmUrl], + ["binaries/programs/wasm32/diffutils/diff.wasm", diffWasmUrl], + ["binaries/programs/wasm32/diffutils/cmp.wasm", cmpWasmUrl], + ["binaries/programs/wasm32/diffutils/diff3.wasm", diff3WasmUrl], + ["binaries/programs/wasm32/diffutils/sdiff.wasm", sdiffWasmUrl], + ["binaries/programs/wasm32/ncurses/clear.wasm", ncursesClearWasmUrl], + ["binaries/programs/wasm32/ncurses/reset.wasm", ncursesResetWasmUrl], + ["binaries/programs/wasm32/ncurses/tset.wasm", ncursesTsetWasmUrl], + ["binaries/programs/wasm32/ncurses/tput.wasm", ncursesTputWasmUrl], + ["binaries/programs/wasm32/ncurses/tabs.wasm", ncursesTabsWasmUrl], + ["binaries/programs/wasm32/ncurses/tic.wasm", ncursesTicWasmUrl], + ["binaries/programs/wasm32/ncurses/infocmp.wasm", ncursesInfocmpWasmUrl], + ["binaries/programs/wasm32/ncurses/toe.wasm", ncursesToeWasmUrl], + ["binaries/programs/wasm32/ncurses/captoinfo.wasm", ncursesCaptoinfoWasmUrl], + ["binaries/programs/wasm32/ncurses/infotocap.wasm", ncursesInfotocapWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ar.wasm", posixArWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/asa.wasm", posixAsaWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/cal.wasm", posixCalWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/cflow.wasm", posixCflowWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/compress.wasm", posixCompressWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ctags.wasm", posixCtagsWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/cxref.wasm", posixCxrefWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ed.wasm", posixEdWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ex.wasm", posixExWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/fuser.wasm", posixFuserWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/gencat.wasm", posixGencatWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/getconf.wasm", posixGetconfWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/gettext.wasm", posixGettextWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/iconv.wasm", posixIconvWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ipcrm.wasm", posixIpcrmWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ipcs.wasm", posixIpcsWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/lex.wasm", posixLexWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/locale.wasm", posixLocaleWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/logger.wasm", posixLoggerWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/man.wasm", posixManWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/more.wasm", posixMoreWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/msgfmt.wasm", posixMsgfmtWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ngettext.wasm", posixNgettextWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/nm.wasm", posixNmWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/patch.wasm", posixPatchWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/pax.wasm", posixPaxWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/pgrep.wasm", posixPgrepWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/ps.wasm", posixPsWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/renice.wasm", posixReniceWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/strings.wasm", posixStringsWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/strip.wasm", posixStripWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/uncompress.wasm", posixUncompressWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/uudecode.wasm", posixUudecodeWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/uuencode.wasm", posixUuencodeWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/what.wasm", posixWhatWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/xgettext.wasm", posixXgettextWasmUrl], + ["binaries/programs/wasm32/posix-utils-lite/yacc.wasm", posixYaccWasmUrl], +]); + +const ROOTFS_LAZY_SOURCE_URL_SET = new Set(ROOTFS_LAZY_ASSET_URLS.keys()); +const ROOTFS_LAZY_ASSET_URL_SET = new Set(ROOTFS_LAZY_ASSET_URLS.values()); + +export function isRootfsLazyFileUrl(url: string): boolean { + return ROOTFS_LAZY_SOURCE_URL_SET.has(url) || ROOTFS_LAZY_ASSET_URL_SET.has(url); +} + +export function rewriteRootfsLazyFileUrls(fs: MemoryFileSystem): void { + fs.rewriteLazyFileUrls((url) => ROOTFS_LAZY_ASSET_URLS.get(url) ?? url); +} diff --git a/apps/browser-demos/lib/init/shell-lazy-files.ts b/apps/browser-demos/lib/init/shell-lazy-files.ts index 060d441345..329cc09f91 100644 --- a/apps/browser-demos/lib/init/shell-lazy-files.ts +++ b/apps/browser-demos/lib/init/shell-lazy-files.ts @@ -6,11 +6,12 @@ import { SHELL_LAZY_BINARY_SPECS, shellLazyPlaceholderUrl, } from "../../../../images/vfs/lib/init/shell-binaries"; +import { + isRootfsLazyFileUrl, + rewriteRootfsLazyFileUrls, +} from "./rootfs-lazy-files"; -import dashWasmUrl from "@binaries/programs/wasm32/dash.wasm?url"; -import bashWasmUrl from "@binaries/programs/wasm32/bash.wasm?url"; import coreutilsWasmUrl from "@binaries/programs/wasm32/coreutils.wasm?url"; -import gawkWasmUrl from "@binaries/programs/wasm32/gawk.wasm?url"; import grepWasmUrl from "@binaries/programs/wasm32/grep.wasm?url"; import sedWasmUrl from "@binaries/programs/wasm32/sed.wasm?url"; import bcWasmUrl from "@binaries/programs/wasm32/bc.wasm?url"; @@ -18,22 +19,6 @@ import fileWasmUrl from "@binaries/programs/wasm32/file/file.wasm?url"; import lessWasmUrl from "@binaries/programs/wasm32/less.wasm?url"; import m4WasmUrl from "@binaries/programs/wasm32/m4.wasm?url"; import makeWasmUrl from "@binaries/programs/wasm32/make.wasm?url"; -import findWasmUrl from "@binaries/programs/wasm32/findutils/find.wasm?url"; -import xargsWasmUrl from "@binaries/programs/wasm32/findutils/xargs.wasm?url"; -import diffWasmUrl from "@binaries/programs/wasm32/diffutils/diff.wasm?url"; -import cmpWasmUrl from "@binaries/programs/wasm32/diffutils/cmp.wasm?url"; -import diff3WasmUrl from "@binaries/programs/wasm32/diffutils/diff3.wasm?url"; -import sdiffWasmUrl from "@binaries/programs/wasm32/diffutils/sdiff.wasm?url"; -import ncursesClearWasmUrl from "@binaries/programs/wasm32/ncurses/clear.wasm?url"; -import ncursesResetWasmUrl from "@binaries/programs/wasm32/ncurses/reset.wasm?url"; -import ncursesTsetWasmUrl from "@binaries/programs/wasm32/ncurses/tset.wasm?url"; -import ncursesTputWasmUrl from "@binaries/programs/wasm32/ncurses/tput.wasm?url"; -import ncursesTabsWasmUrl from "@binaries/programs/wasm32/ncurses/tabs.wasm?url"; -import ncursesTicWasmUrl from "@binaries/programs/wasm32/ncurses/tic.wasm?url"; -import ncursesInfocmpWasmUrl from "@binaries/programs/wasm32/ncurses/infocmp.wasm?url"; -import ncursesToeWasmUrl from "@binaries/programs/wasm32/ncurses/toe.wasm?url"; -import ncursesCaptoinfoWasmUrl from "@binaries/programs/wasm32/ncurses/captoinfo.wasm?url"; -import ncursesInfotocapWasmUrl from "@binaries/programs/wasm32/ncurses/infotocap.wasm?url"; import tarWasmUrl from "@binaries/programs/wasm32/tar.wasm?url"; import curlWasmUrl from "@binaries/programs/wasm32/curl.wasm?url"; import ncWasmUrl from "@binaries/programs/wasm32/nc.wasm?url"; @@ -48,42 +33,6 @@ import zipWasmUrl from "@binaries/programs/wasm32/zip.wasm?url"; import unzipWasmUrl from "@binaries/programs/wasm32/unzip.wasm?url"; import lsofWasmUrl from "@binaries/programs/wasm32/lsof.wasm?url"; import nanoWasmUrl from "@binaries/programs/wasm32/nano.wasm?url"; -import posixArWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ar.wasm?url"; -import posixAsaWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/asa.wasm?url"; -import posixCalWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/cal.wasm?url"; -import posixCflowWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/cflow.wasm?url"; -import posixCompressWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/compress.wasm?url"; -import posixCtagsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ctags.wasm?url"; -import posixCxrefWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/cxref.wasm?url"; -import posixEdWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ed.wasm?url"; -import posixExWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ex.wasm?url"; -import posixFuserWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/fuser.wasm?url"; -import posixGencatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gencat.wasm?url"; -import posixGetconfWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/getconf.wasm?url"; -import posixGettextWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/gettext.wasm?url"; -import posixIconvWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/iconv.wasm?url"; -import posixIpcrmWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ipcrm.wasm?url"; -import posixIpcsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ipcs.wasm?url"; -import posixLexWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/lex.wasm?url"; -import posixLocaleWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/locale.wasm?url"; -import posixLoggerWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/logger.wasm?url"; -import posixManWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/man.wasm?url"; -import posixMoreWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/more.wasm?url"; -import posixMsgfmtWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/msgfmt.wasm?url"; -import posixNgettextWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ngettext.wasm?url"; -import posixNmWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/nm.wasm?url"; -import posixPatchWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/patch.wasm?url"; -import posixPaxWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/pax.wasm?url"; -import posixPsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/ps.wasm?url"; -import posixReniceWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/renice.wasm?url"; -import posixStringsWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/strings.wasm?url"; -import posixStripWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/strip.wasm?url"; -import posixUncompressWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/uncompress.wasm?url"; -import posixUudecodeWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/uudecode.wasm?url"; -import posixUuencodeWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/uuencode.wasm?url"; -import posixWhatWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/what.wasm?url"; -import posixXgettextWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/xgettext.wasm?url"; -import posixYaccWasmUrl from "@binaries/programs/wasm32/posix-utils-lite/yacc.wasm?url"; const SHELL_LAZY_ASSET_URLS: Record<(typeof SHELL_LAZY_BINARY_SPECS)[number]["id"], string> = { coreutils: coreutilsWasmUrl, @@ -117,85 +66,17 @@ const SHELL_LAZY_PLACEHOLDER_URLS = new Map( ]), ); -const ROOTFS_LAZY_ASSET_URLS = new Map([ - ["binaries/programs/wasm32/dash.wasm", dashWasmUrl], - ["binaries/programs/wasm32/bash.wasm", bashWasmUrl], - ["binaries/programs/wasm32/coreutils.wasm", coreutilsWasmUrl], - ["binaries/programs/wasm32/gawk.wasm", gawkWasmUrl], - ["binaries/programs/wasm32/grep.wasm", grepWasmUrl], - ["binaries/programs/wasm32/sed.wasm", sedWasmUrl], - ["binaries/programs/wasm32/bc.wasm", bcWasmUrl], - ["binaries/programs/wasm32/file/file.wasm", fileWasmUrl], - ["binaries/programs/wasm32/m4.wasm", m4WasmUrl], - ["binaries/programs/wasm32/make.wasm", makeWasmUrl], - ["binaries/programs/wasm32/findutils/find.wasm", findWasmUrl], - ["binaries/programs/wasm32/findutils/xargs.wasm", xargsWasmUrl], - ["binaries/programs/wasm32/diffutils/diff.wasm", diffWasmUrl], - ["binaries/programs/wasm32/diffutils/cmp.wasm", cmpWasmUrl], - ["binaries/programs/wasm32/diffutils/diff3.wasm", diff3WasmUrl], - ["binaries/programs/wasm32/diffutils/sdiff.wasm", sdiffWasmUrl], - ["binaries/programs/wasm32/ncurses/clear.wasm", ncursesClearWasmUrl], - ["binaries/programs/wasm32/ncurses/reset.wasm", ncursesResetWasmUrl], - ["binaries/programs/wasm32/ncurses/tset.wasm", ncursesTsetWasmUrl], - ["binaries/programs/wasm32/ncurses/tput.wasm", ncursesTputWasmUrl], - ["binaries/programs/wasm32/ncurses/tabs.wasm", ncursesTabsWasmUrl], - ["binaries/programs/wasm32/ncurses/tic.wasm", ncursesTicWasmUrl], - ["binaries/programs/wasm32/ncurses/infocmp.wasm", ncursesInfocmpWasmUrl], - ["binaries/programs/wasm32/ncurses/toe.wasm", ncursesToeWasmUrl], - ["binaries/programs/wasm32/ncurses/captoinfo.wasm", ncursesCaptoinfoWasmUrl], - ["binaries/programs/wasm32/ncurses/infotocap.wasm", ncursesInfotocapWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ar.wasm", posixArWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/asa.wasm", posixAsaWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/cal.wasm", posixCalWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/cflow.wasm", posixCflowWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/compress.wasm", posixCompressWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ctags.wasm", posixCtagsWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/cxref.wasm", posixCxrefWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ed.wasm", posixEdWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ex.wasm", posixExWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/fuser.wasm", posixFuserWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/gencat.wasm", posixGencatWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/getconf.wasm", posixGetconfWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/gettext.wasm", posixGettextWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/iconv.wasm", posixIconvWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ipcrm.wasm", posixIpcrmWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ipcs.wasm", posixIpcsWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/lex.wasm", posixLexWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/locale.wasm", posixLocaleWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/logger.wasm", posixLoggerWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/man.wasm", posixManWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/more.wasm", posixMoreWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/msgfmt.wasm", posixMsgfmtWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ngettext.wasm", posixNgettextWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/nm.wasm", posixNmWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/patch.wasm", posixPatchWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/pax.wasm", posixPaxWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/ps.wasm", posixPsWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/renice.wasm", posixReniceWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/strings.wasm", posixStringsWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/strip.wasm", posixStripWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/uncompress.wasm", posixUncompressWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/uudecode.wasm", posixUudecodeWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/uuencode.wasm", posixUuencodeWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/what.wasm", posixWhatWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/xgettext.wasm", posixXgettextWasmUrl], - ["binaries/programs/wasm32/posix-utils-lite/yacc.wasm", posixYaccWasmUrl], -]); - -const SHELL_LAZY_URLS = new Map([ - ...SHELL_LAZY_PLACEHOLDER_URLS, - ...ROOTFS_LAZY_ASSET_URLS, -]); - -const SHELL_LAZY_SOURCE_URL_SET = new Set(SHELL_LAZY_URLS.keys()); -const SHELL_LAZY_ASSET_URL_SET = new Set(SHELL_LAZY_URLS.values()); +const SHELL_LAZY_SOURCE_URL_SET = new Set(SHELL_LAZY_PLACEHOLDER_URLS.keys()); +const SHELL_LAZY_ASSET_URL_SET = new Set(SHELL_LAZY_PLACEHOLDER_URLS.values()); export function rewriteShellLazyFileUrls(fs: MemoryFileSystem): void { - fs.rewriteLazyFileUrls((url) => SHELL_LAZY_URLS.get(url) ?? url); + rewriteRootfsLazyFileUrls(fs); + fs.rewriteLazyFileUrls((url) => SHELL_LAZY_PLACEHOLDER_URLS.get(url) ?? url); } export function shellLazyFileEntries(fs: MemoryFileSystem): LazyFileEntry[] { return fs.exportLazyEntries().filter((entry) => { + if (isRootfsLazyFileUrl(entry.url)) return true; if (SHELL_LAZY_SOURCE_URL_SET.has(entry.url)) return true; return SHELL_LAZY_ASSET_URL_SET.has(entry.url); }); diff --git a/apps/browser-demos/lib/init/vfs-utils.ts b/apps/browser-demos/lib/init/vfs-utils.ts index b7f78e0b97..8adb137791 100644 --- a/apps/browser-demos/lib/init/vfs-utils.ts +++ b/apps/browser-demos/lib/init/vfs-utils.ts @@ -5,39 +5,10 @@ * and are used by demo build scripts that construct VFS images. */ import type { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; - -const encoder = new TextEncoder(); - -/** - * Write a text file to the VFS. Opens with O_WRONLY|O_CREAT|O_TRUNC, - * writes the encoded content, and closes the fd. - */ -export function writeVfsFile( - fs: MemoryFileSystem, - path: string, - content: string, - mode = 0o644, -): void { - const data = encoder.encode(content); - const fd = fs.open(path, 0o1101, mode); // O_WRONLY | O_CREAT | O_TRUNC - fs.write(fd, data, 0, data.length); - fs.close(fd); -} - -/** - * Write a binary file to the VFS. Opens with O_WRONLY|O_CREAT|O_TRUNC, - * writes the raw bytes, and closes the fd. - */ -export function writeVfsBinary( - fs: MemoryFileSystem, - path: string, - data: Uint8Array, - mode = 0o755, -): void { - const fd = fs.open(path, 0o1101, mode); // O_WRONLY | O_CREAT | O_TRUNC - fs.write(fd, data, 0, data.length); - fs.close(fd); -} +export { + writeVfsBinary, + writeVfsFile, +} from "../../../../host/src/vfs/image-helpers"; /** * Create a directory, ignoring EEXIST errors. @@ -79,4 +50,3 @@ export function ensureDirRecursive( ensureDir(fs, current, mode); } } - diff --git a/apps/browser-demos/lib/kernel-owned-boot.ts b/apps/browser-demos/lib/kernel-owned-boot.ts index 6f1227fd8a..bd08b08616 100644 --- a/apps/browser-demos/lib/kernel-owned-boot.ts +++ b/apps/browser-demos/lib/kernel-owned-boot.ts @@ -91,48 +91,114 @@ export function createEmptyBuildFs(maxByteLength = 64 * 1024 * 1024): MemoryFile return MemoryFileSystem.create(sab, maxByteLength); } -/** - * Copy `/etc/*` regular files from a rootfs image into `target`, without - * overwriting files the caller already wrote. This replaces the legacy - * worker-side `/etc` overlay that `kernel.init()` performed: demos that used to - * start from an empty FS and depend on `/etc/{passwd,group,hosts,services}` for - * `getpwnam`/`gethostbyname` must now bake `/etc` into their image. - */ -export function overlayEtcFromRootfs(target: MemoryFileSystem, rootfsImage: Uint8Array): void { - const source = MemoryFileSystem.fromImage(rootfsImage); - try { target.mkdir("/etc", 0o755); } catch { /* exists */ } +const S_IFMT = 0xf000; +const S_IFREG = 0x8000; +const S_IFDIR = 0x4000; +const S_IFLNK = 0xa000; - let dh: number; - try { dh = source.opendir("/etc"); } - catch { return; /* no /etc in image */ } +function copyMissingRootfsPath( + source: MemoryFileSystem, + target: MemoryFileSystem, + path: string, +): void { + const sourceStat = source.lstat(path); + const sourceKind = sourceStat.mode & S_IFMT; + let targetKind: number | null = null; try { - for (;;) { - const entry = source.readdir(dh); - if (entry === null) break; - if (entry.name === "." || entry.name === "..") continue; - const path = `/etc/${entry.name}`; - let exists = false; - try { target.stat(path); exists = true; } catch { /* not present */ } - if (exists) continue; - const st = source.stat(path); - if ((st.mode & 0xf000) !== 0x8000) continue; // regular files only - const size = st.size; - const buf = new Uint8Array(size); - const fdR = source.open(path, 0, 0); - let read = 0; - while (read < size) { - const n = source.read(fdR, buf.subarray(read), null, size - read); - if (n <= 0) break; - read += n; + targetKind = target.lstat(path).mode & S_IFMT; + } catch { + // Missing in the caller's image: copy it from the canonical rootfs. + } + + // Existing caller-owned leaves always win. Existing directories merge with + // canonical directories so a demo can override one file without losing the + // rest of that subtree. + if (targetKind !== null && (sourceKind !== S_IFDIR || targetKind !== S_IFDIR)) { + return; + } + + if (sourceKind === S_IFDIR) { + if (targetKind === null) { + target.mkdirWithOwner( + path, + sourceStat.mode & 0o7777, + sourceStat.uid, + sourceStat.gid, + ); + } + const dh = source.opendir(path); + try { + for (;;) { + const entry = source.readdir(dh); + if (entry === null) break; + if (entry.name === "." || entry.name === "..") continue; + copyMissingRootfsPath( + source, + target, + path === "/" ? `/${entry.name}` : `${path}/${entry.name}`, + ); } - source.close(fdR); - const fdW = target.open(path, 0o1101 /* O_WRONLY|O_CREAT|O_TRUNC */, st.mode & 0o777); - if (read > 0) target.write(fdW, buf.subarray(0, read), null, read); - target.close(fdW); + } finally { + source.closedir(dh); + } + return; + } + + if (sourceKind === S_IFLNK) { + target.symlinkWithOwner( + source.readlink(path), + path, + sourceStat.uid, + sourceStat.gid, + ); + return; + } + + if (sourceKind !== S_IFREG) { + throw new Error(`Unsupported canonical /etc file type at ${path}`); + } + + const bytes = new Uint8Array(sourceStat.size); + const fd = source.open(path, 0, 0); + let offset = 0; + try { + while (offset < bytes.length) { + const count = source.read( + fd, + bytes.subarray(offset), + null, + bytes.length - offset, + ); + if (count <= 0) { + throw new Error( + `Short read while copying canonical rootfs path ${path}: ` + + `${offset}/${bytes.length} bytes`, + ); + } + offset += count; } } finally { - source.closedir(dh); + source.close(fd); } + target.createFileWithOwner( + path, + sourceStat.mode & 0o7777, + sourceStat.uid, + sourceStat.gid, + bytes, + ); +} + +/** + * Recursively merge canonical `/etc` state into `target`, without overwriting + * files or symlinks the caller already wrote. This replaces the legacy + * worker-side `/etc` overlay that `kernel.init()` performed: demos that start + * from a small custom image still inherit rootfs-owned account, resolver, TLS, + * and other configuration through the normal VFS path. + */ +export function overlayEtcFromRootfs(target: MemoryFileSystem, rootfsImage: Uint8Array): void { + const source = MemoryFileSystem.fromImage(rootfsImage); + copyMissingRootfsPath(source, target, "/etc"); } /** diff --git a/apps/browser-demos/pages/benchmark/main.ts b/apps/browser-demos/pages/benchmark/main.ts index 30afefc18a..0d145761f3 100644 --- a/apps/browser-demos/pages/benchmark/main.ts +++ b/apps/browser-demos/pages/benchmark/main.ts @@ -663,6 +663,29 @@ async function runWordPress(): Promise> { // ─── mariadb ──────────────────────────────────────────────────────────────── type MariaDbArch = "wasm32" | "wasm64"; +const MARIADB_PROCESS_TIMEOUT_MS = 120_000; +const MARIADB_CLEANUP_TIMEOUT_MS = 10_000; + +async function withRejectingTimeout( + operation: Promise, + timeoutMs: number, + message: string, +): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + +function mariadbOutputSuffix(output: string): string { + const trimmed = output.trim(); + return trimmed ? `:\n${trimmed}` : ""; +} async function runMariaDbWithEngine(engine: string, arch: MariaDbArch = "wasm32"): Promise> { const results: Record = {}; @@ -721,18 +744,29 @@ async function runMariaDbWithEngine(engine: string, arch: MariaDbArch = "wasm32" log(" Running bootstrap..."); const tBootstrap = performance.now(); + let mariadbOutput = ""; + const appendMariaDbOutput = (data: Uint8Array) => { + mariadbOutput += new TextDecoder().decode(data); + if (mariadbOutput.length > 16_384) { + mariadbOutput = mariadbOutput.slice(-16_384); + } + }; const bootstrapKernel = new BrowserKernel({ kernelOwnedFs: true, maxWorkers: 12, - onStdout: () => {}, - onStderr: () => {}, + onStdout: appendMariaDbOutput, + onStderr: appendMariaDbOutput, }); await bootstrapKernel.initFromImage({ kernelWasm: kernelBytes, vfsImage }); let client: MySqlBrowserClient | null = null; + let serverPid: number | undefined; + let serverExit: Promise | undefined; + let serverExitStatus: number | undefined; + let prematureServerExitObserved = false; try { const bootstrapStdin = new TextEncoder().encode(bootstrapSql); - bootstrapKernel.spawn(mariadbBytes, [ + const bootstrapExit = bootstrapKernel.spawn(mariadbBytes, [ "mariadbd", "--no-defaults", "--bootstrap", "--user=mysql", "--datadir=/data", "--tmpdir=/data/tmp", @@ -742,26 +776,28 @@ async function runMariaDbWithEngine(engine: string, arch: MariaDbArch = "wasm32" "--sort-buffer-size=262144", "--skip-networking", "--log-warnings=0", ], { stdin: bootstrapStdin }); - // Wait for bootstrap stdin to be consumed - const bootstrapPid = (bootstrapKernel as any).nextPid - 1; - for (let i = 0; i < 1200; i++) { - try { - const consumed = await bootstrapKernel.isStdinConsumed(bootstrapPid); - if (consumed) { - await new Promise((r) => setTimeout(r, 2000)); - break; - } - } catch { break; } - await new Promise((r) => setTimeout(r, 500)); + const bootstrapExitCode = await withRejectingTimeout( + bootstrapExit, + MARIADB_PROCESS_TIMEOUT_MS, + `MariaDB ${arch} bootstrap timed out after ${MARIADB_PROCESS_TIMEOUT_MS}ms`, + ); + if (bootstrapExitCode !== 0) { + throw new Error( + `MariaDB ${arch} bootstrap exited with status ${bootstrapExitCode}` + + mariadbOutputSuffix(mariadbOutput), + ); } - try { await bootstrapKernel.terminateProcess(bootstrapPid); } catch {} results.bootstrap_ms = performance.now() - tBootstrap; log(` Bootstrap: ${results.bootstrap_ms.toFixed(0)}ms`); // ── Server ── log(" Starting server..."); - const serverExit = bootstrapKernel.spawn(mariadbBytes, [ + let resolveServerStarted: ((pid: number) => void) | undefined; + const serverStarted = new Promise((resolveStarted) => { + resolveServerStarted = resolveStarted; + }); + serverExit = bootstrapKernel.spawn(mariadbBytes, [ "mariadbd", "--no-defaults", "--user=mysql", "--datadir=/data", "--tmpdir=/data/tmp", @@ -771,19 +807,67 @@ async function runMariaDbWithEngine(engine: string, arch: MariaDbArch = "wasm32" "--sort-buffer-size=262144", "--skip-networking=0", "--port=3306", "--bind-address=0.0.0.0", "--socket=", "--max-connections=10", - ]); - void serverExit; + ], { + // onStarted otherwise changes omitted stdin from immediate EOF to an + // appendable stream. Keep the benchmark server's prior stdin contract. + stdin: new Uint8Array(0), + onStarted: (pid) => resolveServerStarted?.(pid), + }); + void serverExit.then( + (status) => { serverExitStatus = status; }, + () => {}, + ); + serverPid = await withRejectingTimeout( + Promise.race([ + serverStarted, + serverExit.then((status) => { + throw new Error( + `MariaDB ${arch} server exited with status ${status} before startup completed` + + mariadbOutputSuffix(mariadbOutput), + ); + }), + ]), + MARIADB_PROCESS_TIMEOUT_MS, + `MariaDB ${arch} server startup timed out after ${MARIADB_PROCESS_TIMEOUT_MS}ms`, + ); + + const serverExitError = (activity: string, status: number) => new Error( + `MariaDB ${arch} server exited with status ${status} ${activity}` + + mariadbOutputSuffix(mariadbOutput), + ); + const assertLiveServer = (activity: string) => { + if (serverExitStatus === undefined) return; + prematureServerExitObserved = true; + throw serverExitError(activity, serverExitStatus); + }; + const raceLiveServer = async (operation: Promise, activity: string): Promise => { + assertLiveServer(activity); + return Promise.race([ + operation, + serverExit!.then((status) => { + prematureServerExitObserved = true; + throw serverExitError(activity, status); + }), + ]); + }; // Wait for port 3306 log(" Waiting for server to accept connections..."); let listenerReady = false; for (let i = 0; i < 120; i++) { - const target = await bootstrapKernel.pickListenerTarget(3306); + assertLiveServer("while waiting for readiness"); + const target = await raceLiveServer( + bootstrapKernel.pickListenerTarget(3306), + "while waiting for readiness", + ); if (target) { listenerReady = true; break; } - await new Promise((r) => setTimeout(r, 1000)); + await raceLiveServer( + new Promise((resolveSleep) => setTimeout(resolveSleep, 1000)), + "while waiting for readiness", + ); } if (!listenerReady) { throw new Error(`mariadb-${engine.toLowerCase()}${suiteSuffix} did not listen on port 3306`); @@ -792,49 +876,105 @@ async function runMariaDbWithEngine(engine: string, arch: MariaDbArch = "wasm32" // Connect MySQL client with retries for (let attempt = 1; attempt <= 10; attempt++) { try { - client = await MySqlBrowserClient.connect(bootstrapKernel, 3306); + client = await raceLiveServer( + MySqlBrowserClient.connect(bootstrapKernel, 3306), + "during the MySQL handshake", + ); break; } catch { + assertLiveServer("during the MySQL handshake"); if (attempt === 10) throw new Error("MySQL handshake failed after 10 attempts"); - await new Promise((r) => setTimeout(r, 2000)); + await raceLiveServer( + new Promise((resolveSleep) => setTimeout(resolveSleep, 2000)), + "during the MySQL handshake", + ); } } if (!client) throw new Error("Failed to connect MySQL client"); + const liveClient = client; // ── Queries ── log(" Running CREATE TABLE..."); const t1 = performance.now(); - await client.query("CREATE DATABASE IF NOT EXISTS bench"); - await client.query(`CREATE TABLE bench.t1 (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100), value INT) ENGINE=${engine}`); - await client.query(`CREATE TABLE bench.t2 (id INT PRIMARY KEY AUTO_INCREMENT, t1_id INT, data VARCHAR(200)) ENGINE=${engine}`); + await raceLiveServer( + (async () => { + await liveClient.query("CREATE DATABASE IF NOT EXISTS bench"); + await liveClient.query(`CREATE TABLE bench.t1 (id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100), value INT) ENGINE=${engine}`); + await liveClient.query(`CREATE TABLE bench.t2 (id INT PRIMARY KEY AUTO_INCREMENT, t1_id INT, data VARCHAR(200)) ENGINE=${engine}`); + })(), + "during CREATE operations", + ); results.query_create_ms = performance.now() - t1; log(" Running INSERT (100 rows)..."); const t2 = performance.now(); - for (let i = 0; i < 100; i++) { - await client.query(`INSERT INTO bench.t1 (name, value) VALUES ('item_${i}', ${i * 10})`); - } - for (let i = 0; i < 100; i++) { - await client.query(`INSERT INTO bench.t2 (t1_id, data) VALUES (${i + 1}, 'data_for_item_${i}')`); - } + await raceLiveServer((async () => { + for (let i = 0; i < 100; i++) { + await liveClient.query(`INSERT INTO bench.t1 (name, value) VALUES ('item_${i}', ${i * 10})`); + } + for (let i = 0; i < 100; i++) { + await liveClient.query(`INSERT INTO bench.t2 (t1_id, data) VALUES (${i + 1}, 'data_for_item_${i}')`); + } + })(), "during INSERT operations"); results.query_insert_ms = performance.now() - t2; log(" Running SELECT..."); const t3 = performance.now(); - await client.query("SELECT * FROM bench.t1 WHERE value > 500 AND value < 800"); + await raceLiveServer( + liveClient.query("SELECT * FROM bench.t1 WHERE value > 500 AND value < 800"), + "during SELECT", + ); results.query_select_ms = performance.now() - t3; log(" Running JOIN..."); const t4 = performance.now(); - await client.query("SELECT t1.name, t2.data FROM bench.t1 t1 JOIN bench.t2 t2 ON t1.id = t2.t1_id WHERE t1.value > 500"); + await raceLiveServer( + liveClient.query("SELECT t1.name, t2.data FROM bench.t1 t1 JOIN bench.t2 t2 ON t1.id = t2.t1_id WHERE t1.value > 500"), + "during JOIN", + ); results.query_join_ms = performance.now() - t4; return results; } finally { try { client?.close(); } catch {} - try { await bootstrapKernel.destroy(); } catch {} + let cleanupError: unknown; + if (serverExit !== undefined && serverPid !== undefined) { + if (serverExitStatus === undefined) { + try { + await bootstrapKernel.terminateProcess(serverPid, 137); + } catch (error) { + cleanupError = error; + } + try { + const exitCode = await withRejectingTimeout( + serverExit, + MARIADB_CLEANUP_TIMEOUT_MS, + `MariaDB ${arch} server did not exit after deliberate termination`, + ); + if (exitCode !== 137) { + cleanupError = new Error( + `MariaDB ${arch} server exited with status ${exitCode} before deliberate cleanup completed` + + mariadbOutputSuffix(mariadbOutput), + ); + } + } catch (error) { + cleanupError ??= error; + } + } else if (!prematureServerExitObserved) { + cleanupError = new Error( + `MariaDB ${arch} server exited with status ${serverExitStatus} before deliberate cleanup began` + + mariadbOutputSuffix(mariadbOutput), + ); + } + } + try { + await bootstrapKernel.destroy(); + } catch (error) { + cleanupError ??= error; + } await settleWebKitReclaim(); + if (cleanupError !== undefined) throw cleanupError; } } diff --git a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts index 6f4ab091b1..7844c88bb6 100644 --- a/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts +++ b/apps/browser-demos/pages/kandelo/kernel-host/live-setup.ts @@ -1174,6 +1174,15 @@ async function bootProfile( maxMemoryPages: profile.init?.maxMemoryPages, onStdout: (data) => recordProcessOutput(data, "stdout"), onStderr: (data) => recordProcessOutput(data, "stderr"), + onHostDiagnostic: (diagnostic) => { + if (!isCurrent()) return; + host.pushDmesg({ + t: bootElapsedMs(bootStartedAt), + level: "warn", + facility: "kernel", + msg: diagnostic.message, + }); + }, onProcessEvent: (event) => { if (isCurrent()) host.emitProcessEvent(event); }, onHttpBridgePendingRequests: (count) => { if (isCurrent()) host.setWebPreviewPendingRequests(count); diff --git a/apps/browser-demos/pages/php-test/index.html b/apps/browser-demos/pages/php-test/index.html new file mode 100644 index 0000000000..d51dfd61ca --- /dev/null +++ b/apps/browser-demos/pages/php-test/index.html @@ -0,0 +1,12 @@ + + + + + PHP PHPT Test Runner + + + +
Loading PHP test image...
+ + + diff --git a/apps/browser-demos/pages/php-test/main.ts b/apps/browser-demos/pages/php-test/main.ts new file mode 100644 index 0000000000..985e5e88e6 --- /dev/null +++ b/apps/browser-demos/pages/php-test/main.ts @@ -0,0 +1,361 @@ +/** + * Browser runner for php-src PHPT tests. + * + * The Node/Playwright driver parses .phpt files and asks this page to run + * transient PHP scripts inside a VFS image containing php-src test assets. + */ +import { BrowserKernel } from "@host/browser-kernel-host"; +import { MemoryFileSystem } from "@host/vfs/memory-fs"; +import kernelWasmUrl from "@kernel-wasm?url"; +import { finalizeKernelOwnedImage } from "../../lib/kernel-owned-boot"; +import { rewriteRootfsLazyFileUrls } from "../../lib/init/rootfs-lazy-files"; + +interface RunPhpScriptRequest { + testId: string; + scriptPath: string; + script: string; + argv: string[]; + cwd: string; + env?: string[]; + uid?: number; + gid?: number; + stdin?: string; + waitForChildOutput?: boolean; + timeoutMs?: number; +} + +interface RunPhpScriptResult { + exitCode: number; + stdout: string; + stderr: string; + output?: string; + error?: string; + durationMs: number; +} + +declare global { + interface Window { + __phpTestReady: boolean; + __runPhpScript: (request: RunPhpScriptRequest) => Promise; + } +} + +let kernelBytes: ArrayBuffer | null = null; +let initialFs: MemoryFileSystem | null = null; +let kernel: BrowserKernel | null = null; +let kernelInitialization: Promise | null = null; +let activeOutput: { stdout: string; stderr: string; output: string } | null = null; + +function createFs(vfsImageBytes: Uint8Array): MemoryFileSystem { + const fs = MemoryFileSystem.fromImage(vfsImageBytes, { + maxByteLength: 2 * 1024 * 1024 * 1024, + }); + // Resolve canonical rootfs placeholders before serializing the transient + // build FS into the image that the kernel worker will own. + rewriteRootfsLazyFileUrls(fs); + return fs; +} + +function makeTreeWritableByGuest( + fs: MemoryFileSystem, + path: string, +): void { + const st = fs.lstat(path); + const kind = st.mode & 0o170000; + if (kind === 0o120000) return; + if (kind === 0o040000) { + fs.chmod(path, 0o777); + const dh = fs.opendir(path); + try { + for (;;) { + const entry = fs.readdir(dh); + if (!entry) break; + if (entry.name === "." || entry.name === "..") continue; + makeTreeWritableByGuest( + fs, + path === "/" ? `/${entry.name}` : `${path}/${entry.name}`, + ); + } + } finally { + fs.closedir(dh); + } + return; + } + fs.chmod(path, (st.mode & 0o111) | 0o666); +} + +function prepareGuestWritableWorkspace( + fs: MemoryFileSystem, + _scriptPath: string, + uid?: number, + gid?: number, +): void { + if (uid == null && gid == null) return; + // Match Node's copied-source contract: directories are world-writable and + // files retain execute bits while becoming writable. Do this once per VFS, + // before any section mutates it. + makeTreeWritableByGuest(fs, "/php-src"); +} + +function binaryStringToBytes(value: string): Uint8Array { + const bytes = new Uint8Array(value.length); + for (let i = 0; i < value.length; i++) { + bytes[i] = value.charCodeAt(i) & 0xff; + } + return bytes; +} + +function bytesToBinaryString(data: Uint8Array): string { + let out = ""; + const chunk = 0x8000; + for (let i = 0; i < data.length; i += chunk) { + out += String.fromCharCode(...data.subarray(i, i + chunk)); + } + return out; +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +async function ensureKernel(uid?: number, gid?: number): Promise { + if (kernel) return kernel; + if (kernelInitialization) return kernelInitialization; + + kernelInitialization = (async () => { + if (!initialFs || !kernelBytes) { + throw new Error("PHP test runtime is not initialized"); + } + const buildFs = initialFs; + prepareGuestWritableWorkspace(buildFs, "/php-src", uid, gid); + const vfsImage = await finalizeKernelOwnedImage(buildFs); + const nextKernel = new BrowserKernel({ + kernelOwnedFs: true, + maxWorkers: 4, + onStdout: (data) => { + if (!activeOutput) return; + const text = bytesToBinaryString(data); + activeOutput.stdout += text; + activeOutput.output += text; + }, + onStderr: (data) => { + if (!activeOutput) return; + const text = bytesToBinaryString(data); + activeOutput.stderr += text; + activeOutput.output += text; + }, + }); + try { + await nextKernel.initFromImage({ + kernelWasm: kernelBytes, + vfsImage, + }); + } catch (err) { + await nextKernel.destroy().catch(() => {}); + throw err; + } + // The worker now owns the live VFS. Drop the transient main-thread build + // filesystem so browser memory reclamation does not depend on page GC. + initialFs = null; + kernel = nextKernel; + return nextKernel; + })(); + + try { + return await kernelInitialization; + } finally { + kernelInitialization = null; + } +} + +async function terminateRemainingProcesses(runtime: BrowserKernel): Promise { + const processes = await runtime.enumProcs(); + if (processes.length === 0) return; + const results = await Promise.allSettled( + processes.map((process) => runtime.terminateProcess(process.pid)), + ); + const failed = results.find( + (result): result is PromiseRejectedResult => result.status === "rejected", + ); + if (failed) throw failed.reason; +} + +async function init() { + const baseUrl = import.meta.env.BASE_URL ?? "/"; + const vfsFile = import.meta.env.VITE_PHP_TEST_VFS_URL ?? "php-test.vfs.zst"; + const vfsUrl = `${baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`}${vfsFile}`; + const [kernelBuf, imageBuf] = await Promise.all([ + fetch(kernelWasmUrl).then((r) => { + if (!r.ok) throw new Error(`kernel fetch failed: ${r.status}`); + return r.arrayBuffer(); + }), + fetch(vfsUrl).then((r) => { + if (!r.ok) { + throw new Error( + `${vfsFile} not found (${r.status}). Run: bash images/vfs/scripts/build-php-test-vfs-image.sh`, + ); + } + return r.arrayBuffer(); + }), + ]); + + kernelBytes = kernelBuf; + const fs = createFs(new Uint8Array(imageBuf)); + const php = fs.stat("/usr/local/bin/php"); + if ((php.mode & 0o170000) !== 0o100000) { + throw new Error("PHP test VFS does not contain a regular /usr/local/bin/php"); + } + initialFs = fs; + + window.__runPhpScript = async (request: RunPhpScriptRequest) => { + const start = performance.now(); + const capture = { stdout: "", stderr: "", output: "" }; + if (activeOutput) { + return { + exitCode: -1, + ...capture, + error: "Concurrent browser PHPT sections are not supported", + durationMs: Math.round(performance.now() - start), + }; + } + let runtime: BrowserKernel | null = null; + let previousScript: Awaited> = null; + let scriptStaged = false; + let timeoutId: number | undefined; + let result: RunPhpScriptResult | null = null; + const stdin = request.stdin == null + ? new Uint8Array() + : binaryStringToBytes(request.stdin); + const env = [ + "HOME=/tmp", + "TMPDIR=/tmp", + "PATH=/usr/local/bin:/usr/bin:/bin", + "TEST_PHP_EXECUTABLE=/usr/local/bin/php", + "TEST_PHP_EXECUTABLE_ESCAPED='/usr/local/bin/php'", + ...(request.env ?? []), + ]; + + try { + runtime = await ensureKernel(request.uid, request.gid); + // The worker owns one persistent PHPT workspace for the page lifetime. + // This matches the Node runner's mounted source tree: SKIPIF, FILE, + // CLEAN, and later tests all observe guest mutations and failed cleanup. + previousScript = await runtime.readFileSnapshotFromVfs(request.scriptPath); + await runtime.writeFileToVfs( + request.scriptPath, + binaryStringToBytes(request.script), + 0o644, + ); + scriptStaged = true; + activeOutput = capture; + + const spawned = await runtime.spawnFromVfs( + "/usr/local/bin/php", + ["/usr/local/bin/php", ...request.argv], + { + cwd: request.cwd, + env, + stdin, + uid: request.uid, + gid: request.gid, + }, + ); + const timeout = new Promise((_, reject) => { + timeoutId = window.setTimeout( + () => reject(new Error("TIMEOUT")), + request.timeoutMs ?? 60_000, + ); + }); + const exitCode = await Promise.race([spawned.exit, timeout]); + + if (request.waitForChildOutput) { + const deadline = performance.now() + 1_000; + while (performance.now() < deadline) { + const processes = await runtime.enumProcs().catch(() => []); + if (processes.length === 0) break; + await delay(25); + } + } + + let lastOutputLength = -1; + let stablePolls = 0; + for (let waitedMs = 0; waitedMs < 500 && stablePolls < 3; waitedMs += 25) { + await delay(25); + const outputLength = capture.output.length; + if (waitedMs >= 100 && outputLength === lastOutputLength) { + stablePolls++; + } else { + stablePolls = 0; + } + lastOutputLength = outputLength; + } + result = { + exitCode, + ...capture, + durationMs: 0, + }; + } catch (err: any) { + const message = err?.message || String(err); + result = { + exitCode: -1, + ...capture, + error: message.includes("TIMEOUT") ? "TIMEOUT" : message, + durationMs: 0, + }; + } finally { + if (timeoutId !== undefined) window.clearTimeout(timeoutId); + let cleanupError: unknown = null; + if (runtime) { + try { + // Upstream runs each PHP invocation in an isolated process tree. + // Preserve the worker-owned VFS, but do not let orphaned children + // leak into the next PHPT section. + await terminateRemainingProcesses(runtime); + } catch (err) { + cleanupError = err; + } + if (scriptStaged) { + try { + if (previousScript) { + await runtime.writeFileToVfs( + request.scriptPath, + previousScript.data, + previousScript.mode, + ); + } else { + await runtime.unlinkFileFromVfs(request.scriptPath); + } + } catch (err) { + cleanupError ??= err; + } + } + } + if (activeOutput === capture) activeOutput = null; + if (cleanupError && result) { + const message = cleanupError instanceof Error + ? cleanupError.message + : String(cleanupError); + result.exitCode = -1; + result.error = result.error + ? `${result.error}; PHPT cleanup failed: ${message}` + : `PHPT cleanup failed: ${message}`; + } + } + result ??= { + exitCode: -1, + ...capture, + error: "PHP browser runner produced no result", + durationMs: 0, + }; + result.durationMs = Math.round(performance.now() - start); + return result; + }; + + window.__phpTestReady = true; + document.getElementById("status")!.textContent = "Ready"; +} + +init().catch((err) => { + console.error(err); + document.getElementById("status")!.textContent = `Error: ${err?.message || err}`; +}); 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/chown-sentinel.spec.ts b/apps/browser-demos/test/chown-sentinel.spec.ts new file mode 100644 index 0000000000..c05e635e83 --- /dev/null +++ b/apps/browser-demos/test/chown-sentinel.spec.ts @@ -0,0 +1,38 @@ +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 programPath = resolve( + __dirname, + "../../../examples/chown_sentinel_test.wasm", +); + +test("chown sentinels and no-follow link ownership work in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + expect(baseURL).toBeTruthy(); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate(async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["chown-sentinel-test"], + 15_000, + ); + }, { programUrl }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("CHOWN_SENTINEL_PASS"); + expect(result.stderr).toBe(""); +}); diff --git a/apps/browser-demos/test/environment-lifecycle.spec.ts b/apps/browser-demos/test/environment-lifecycle.spec.ts new file mode 100644 index 0000000000..2c73feca7c --- /dev/null +++ b/apps/browser-demos/test/environment-lifecycle.spec.ts @@ -0,0 +1,47 @@ +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 programPath = resolve( + __dirname, + "../../../examples/environment_lifecycle_test.wasm", +); + +test("initial, forked, replacement, and empty environments stay coherent", async ({ + page, + baseURL, + browserName, +}) => { + test.skip(browserName !== "chromium", "the aggregate browser gate uses Chromium"); + expect(baseURL).toBeTruthy(); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate(async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + const bytes = await response.arrayBuffer(); + return (window as any).__runTest( + bytes, + ["/bin/environment-lifecycle"], + 30_000, + { + env: ["INITIAL=parent", "REMOVE=before-fork"], + dataFiles: [ + { path: "/bin/environment-lifecycle", useWasmBytes: true }, + ], + }, + ); + }, { programUrl }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("FORK_ENV_PASS"); + expect(result.stdout).toContain("EXEC_ENV_PASS"); + expect(result.stdout).toContain("EMPTY_ENV_PASS"); + expect(result.stderr).toBe(""); +}); diff --git a/apps/browser-demos/test/fixtures/opfs-pathconf-client-worker.ts b/apps/browser-demos/test/fixtures/opfs-pathconf-client-worker.ts new file mode 100644 index 0000000000..655559e6a6 --- /dev/null +++ b/apps/browser-demos/test/fixtures/opfs-pathconf-client-worker.ts @@ -0,0 +1,73 @@ +import { PATHCONF_NAMES } from "../../../../host/src/generated/abi"; +import { OpfsFileSystem } from "../../../../host/src/vfs/opfs"; + +const O_RDWR = 0x0002; +const O_CREAT = 0x0040; +const O_TRUNC = 0x0200; + +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 result = { + type: "result", + // Keep the synchronous access handle open while pathname queries run: + // Chromium permits FileSystemFileHandle.getFile() here, but rejects a + // second createSyncAccessHandle() for the same file. + nameMax: fs.pathconf(path, PATHCONF_NAMES.NAME_MAX), + pathMax: fs.fpathconf(fd, PATHCONF_NAMES.PATH_MAX), + asyncIo: fs.fpathconf(fd, PATHCONF_NAMES.ASYNC_IO), + symlinks: fs.pathconf(path, PATHCONF_NAMES.POSIX2_SYMLINKS), + timestampResolution: fs.pathconf( + path, + PATHCONF_NAMES.TIMESTAMP_RESOLUTION, + ), + }; + + const closedFd = fd; + fs.close(closedFd); + fd = -1; + const closedHandleError = errorName(() => + fs.fpathconf(closedFd, PATHCONF_NAMES.NAME_MAX), + ); + fs.unlink(path); + const missingPathError = errorName(() => + fs.pathconf(path, PATHCONF_NAMES.NAME_MAX), + ); + + self.postMessage({ ...result, closedHandleError, missingPathError }); + } 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/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/openssl-rootfs.spec.ts b/apps/browser-demos/test/openssl-rootfs.spec.ts new file mode 100644 index 0000000000..105940bcfc --- /dev/null +++ b/apps/browser-demos/test/openssl-rootfs.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = resolve(__dirname, "../../.."); +const programPath = resolve(repoRoot, "examples/mount_probe_test.wasm"); + +const entries = [ + { + path: "/etc/ssl/openssl.cnf", + source: resolve(repoRoot, "images/rootfs/etc/ssl/openssl.cnf"), + }, + { + path: "/etc/ssl/cert.pem", + source: resolve(repoRoot, "images/rootfs/etc/ssl/cert.pem"), + }, +].map(({ path, source }) => { + const bytes = readFileSync(source); + return { + path, + size: bytes.byteLength, + head: bytes.subarray(0, 16).toString("hex"), + }; +}); + +test("legacy browser boots receive rootfs-owned OpenSSL defaults", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") runtimeErrors.push(`console: ${msg.text()}`); + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const results = await page.evaluate( + async ({ programUrl, paths }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + const program = await response.arrayBuffer(); + const output = []; + for (const path of paths) { + output.push( + await (window as any).__runTest( + program.slice(0), + ["mount_probe_test", "rootfs", path], + 30_000, + ), + ); + } + return output; + }, + { programUrl, paths: entries.map((entry) => entry.path) }, + ); + + for (const [index, entry] of entries.entries()) { + const result = results[index]; + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain(`ROOTFS size=${entry.size}`); + expect(result.stdout).toContain(`head=${entry.head}`); + expect(result.stderr).toBe(""); + } + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/opfs-pathconf.spec.ts b/apps/browser-demos/test/opfs-pathconf.spec.ts new file mode 100644 index 0000000000..02443d3f8e --- /dev/null +++ b/apps/browser-demos/test/opfs-pathconf.spec.ts @@ -0,0 +1,110 @@ +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-pathconf-client-worker.ts", +); + +test("OPFS reports path configuration from live paths and handles", 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"; + nameMax: number; + pathMax: number; + asyncIo: number; + symlinks: null; + timestampResolution: null; + closedHandleError: string; + missingPathError: string; + }>(client, "result"); + client.postMessage({ + buffer, + path: `/kandelo-opfs-pathconf-${crypto.randomUUID()}`, + }); + return await pending; + } finally { + client.terminate(); + proxy.terminate(); + } + }, + { proxyWorkerUrl, clientWorkerUrl }, + ); + + expect(result).toEqual({ + type: "result", + nameMax: 255, + pathMax: 4096, + asyncIo: 1, + symlinks: null, + timestampResolution: null, + closedHandleError: "EBADF", + missingPathError: "ENOENT", + }); +}); 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/apps/browser-demos/test/path-resolution.spec.ts b/apps/browser-demos/test/path-resolution.spec.ts new file mode 100644 index 0000000000..02766dbe10 --- /dev/null +++ b/apps/browser-demos/test/path-resolution.spec.ts @@ -0,0 +1,61 @@ +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 programPath = resolve( + __dirname, + "../../../examples/mount_probe_test.wasm", +); + +test("browser resolves pathname components and rejects an invalid initial cwd", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + const program = await response.arrayBuffer(); + const probe = await (window as any).__runTest( + program.slice(0), + ["mount_probe_test", "path-resolution", "/tmp/kandelo-path-resolution"], + 15_000, + { + dataFiles: [{ path: "/etc/services", data: [1, 2, 3, 4] }], + }, + ); + const cwdError = await (window as any) + .__runTest( + program.slice(0), + ["mount_probe_test", "rootfs", "/etc/services"], + 15_000, + { cwd: "/no/such/cwd" }, + ) + .then( + () => null, + (error: unknown) => String(error), + ); + return { probe, cwdError }; + }, + { programUrl }, + ); + + expect(result.probe.exitCode, result.probe.stderr).toBe(0); + expect(result.probe.stdout).toContain("PATH_RESOLUTION_PASS"); + expect(result.probe.stderr).toBe(""); + expect(result.cwdError).toMatch(/setCwd failed for pid \d+: errno 2/); +}); diff --git a/apps/browser-demos/test/pathconf.spec.ts b/apps/browser-demos/test/pathconf.spec.ts new file mode 100644 index 0000000000..69fd6bd434 --- /dev/null +++ b/apps/browser-demos/test/pathconf.spec.ts @@ -0,0 +1,52 @@ +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 programPath = resolve(__dirname, "../../../examples/pathconf_test.wasm"); + +test("pathconf values, errno, and pointer validation work in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["pathconf-test"], + 15_000, + ); + }, + { programUrl }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PATHCONF_PASS"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/pthread-channel-reuse.spec.ts b/apps/browser-demos/test/pthread-channel-reuse.spec.ts new file mode 100644 index 0000000000..bedf29d890 --- /dev/null +++ b/apps/browser-demos/test/pthread-channel-reuse.spec.ts @@ -0,0 +1,57 @@ +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 programPath = resolve( + __dirname, + "../../../examples/pthread_channel_reuse_test.wasm", +); + +test("a recycled pthread channel dispatches in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => + runtimeErrors.push(`pageerror: ${error.message}`), + ); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["pthread-channel-reuse-test"], + 30_000, + ); + }, + { programUrl }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("PTHREAD_CHANNEL_REUSE_PASS"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/rlimit-fsize.spec.ts b/apps/browser-demos/test/rlimit-fsize.spec.ts new file mode 100644 index 0000000000..f0a7f1d548 --- /dev/null +++ b/apps/browser-demos/test/rlimit-fsize.spec.ts @@ -0,0 +1,63 @@ +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 programPath = resolve( + __dirname, + "../../../examples/rlimit_fsize_test.wasm", +); + +test("RLIMIT_FSIZE keeps one operation boundary in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") runtimeErrors.push(`console: ${msg.text()}`); + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["rlimit-fsize-test"], + 30_000, + ); + }, + { programUrl }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("RLIMIT_FSIZE_PASS"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/sched-getaffinity.spec.ts b/apps/browser-demos/test/sched-getaffinity.spec.ts new file mode 100644 index 0000000000..6ea435dea1 --- /dev/null +++ b/apps/browser-demos/test/sched-getaffinity.spec.ts @@ -0,0 +1,75 @@ +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 programPath = resolve( + __dirname, + "../../../local-binaries/programs/wasm32/sched-getaffinity.wasm", +); + +test("sched_getaffinity preserves raw and libc semantics in Chromium", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + const programUrl = new URL( + "/__test-fixtures__/sched-getaffinity.wasm", + baseURL, + ).href; + await page.route(programUrl, async (route) => { + await route.fulfill({ + path: programPath, + contentType: "application/wasm", + }); + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["sched-getaffinity"], + 15_000, + ); + }, + { programUrl }, + ); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toBe("sched-getaffinity-ok raw=4 cpus=1\n"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/unix-listener-exec.spec.ts b/apps/browser-demos/test/unix-listener-exec.spec.ts new file mode 100644 index 0000000000..54760cc898 --- /dev/null +++ b/apps/browser-demos/test/unix-listener-exec.spec.ts @@ -0,0 +1,68 @@ +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 programPath = resolve( + __dirname, + "../../../examples/unix_listener_exec_test.wasm", +); + +test("an inherited AF_UNIX listener keeps its queue across exec", async ({ + page, + baseURL, + browserName, +}) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") { + runtimeErrors.push(`console: ${msg.text()}`); + } + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction(() => (window as any).__testRunnerReady === true); + + const programUrl = new URL(`/@fs/${programPath}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error(`program fetch failed: ${response.status}`); + } + return (window as any).__runTest( + await response.arrayBuffer(), + ["/bin/unix-listener-exec"], + 30_000, + { + dataFiles: [{ path: "/bin/unix-listener-exec", useWasmBytes: true }], + }, + ); + }, + { programUrl }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("UNIX_LISTENER_EXEC_PASS"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); +}); diff --git a/apps/browser-demos/test/wait-lifecycle.spec.ts b/apps/browser-demos/test/wait-lifecycle.spec.ts new file mode 100644 index 0000000000..970c23141d --- /dev/null +++ b/apps/browser-demos/test/wait-lifecycle.spec.ts @@ -0,0 +1,89 @@ +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 programs = [ + { + name: "wasm32", + path: resolve(__dirname, "../../../examples/wait_lifecycle_test.wasm"), + selfSpawnPath: undefined, + }, + { + name: "memory64", + path: resolve( + __dirname, + "../../../examples/wait_lifecycle_test.wasm64.wasm", + ), + selfSpawnPath: "/wait-lifecycle-test-wasm64", + }, +]; + +for (const program of programs) + test(`child wait lifecycle works in Chromium (${program.name})`, async ({ + page, + baseURL, + browserName, + }) => { + test.skip( + browserName !== "chromium", + "the aggregate browser gate uses Chromium", + ); + expect(baseURL).toBeTruthy(); + + const runtimeErrors: string[] = []; + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("console", (message) => { + if (message.type() === "error") { + runtimeErrors.push(`console: ${message.text()}`); + } + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + await page.goto(new URL("/pages/test-runner/", baseURL).href); + await page.waitForFunction( + () => (window as any).__testRunnerReady === true, + ); + + const programUrl = new URL(`/@fs/${program.path}`, baseURL).href; + const result = await page.evaluate( + async ({ programUrl, selfSpawnPath }) => { + const response = await fetch(programUrl); + if (!response.ok) { + throw new Error( + `program fetch failed: ${response.status} ${response.url}`, + ); + } + const wasmBytes = await response.arrayBuffer(); + return (window as any).__runTest( + wasmBytes, + ["wait-lifecycle-test"], + 30_000, + selfSpawnPath + ? { + dataFiles: [ + { path: selfSpawnPath, useWasmBytes: true }, + ], + } + : undefined, + ); + }, + { programUrl, selfSpawnPath: program.selfSpawnPath }, + ); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("WAIT_LIFECYCLE_PASS"); + expect(result.stderr).toBe(""); + expect(runtimeErrors).toEqual([]); + }); diff --git a/apps/browser-demos/vite.config.ts b/apps/browser-demos/vite.config.ts index 12a35be287..7d75bbe0ee 100644 --- a/apps/browser-demos/vite.config.ts +++ b/apps/browser-demos/vite.config.ts @@ -2,7 +2,12 @@ import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; import { execSync } from "child_process"; -import { defineConfig, type Plugin, type PreviewServer, type ViteDevServer } from "vite"; +import { + defineConfig, + type Plugin, + type PreviewServer, + type ViteDevServer, +} from "vite"; import react from "@vitejs/plugin-react"; import { tryResolveBinary } from "../../host/src/binary-resolver"; @@ -39,7 +44,10 @@ function devCorsProxyFetchUrlForBase(base: string): string { return `${devCorsProxyPathForBase(base)}?url=`; } -function injectCorsProxyUrlPlaceholder(content: string, corsProxyUrl: string): string { +function injectCorsProxyUrlPlaceholder( + content: string, + corsProxyUrl: string, +): string { return content.replace('"__CORS_PROXY_URL__"', JSON.stringify(corsProxyUrl)); } @@ -97,7 +105,7 @@ function resolveKernelArtifactsAlias(): Plugin { const fetched = path.resolve(repoRoot, "binaries/kernel.wasm"); this.error( "kernel.wasm not found, or every candidate is stale. Run `bash build.sh` from the repo root.\n" + - ` Looked at: ${local}\n Looked at: ${fetched}` + ` Looked at: ${local}\n Looked at: ${fetched}`, ); } if (pathPart === ROOTFS) { @@ -113,7 +121,7 @@ function resolveKernelArtifactsAlias(): Plugin { } this.error( "rootfs.vfs not found. Run `bash build.sh` from the repo root, or fetch/build the rootfs package.\n" + - candidates.map((file) => ` Looked at: ${file}`).join("\n") + candidates.map((file) => ` Looked at: ${file}`).join("\n"), ); } return null; @@ -201,8 +209,8 @@ function resolveBinariesAlias(): Plugin { const fetched = path.resolve(repoRoot, "binaries", rest); this.error( `@binaries: ${rest} not found, or every candidate is stale. ` + - `Looked at:\n ${local}\n ${fetched}\n` + - `Run \`./run.sh fetch\` to install release archives, or build the artifact locally.` + `Looked at:\n ${local}\n ${fetched}\n` + + `Run \`./run.sh fetch\` to install release archives, or build the artifact locally.`, ); }, }; @@ -253,9 +261,7 @@ function injectGitRevision(): Plugin { encoding: "utf-8", }).trim(); // Convert git@github.com:user/repo.git or https://github.com/user/repo.git - const match = remoteUrl.match( - /github\.com[:/](.+?)(?:\.git)?$/ - ); + const match = remoteUrl.match(/github\.com[:/](.+?)(?:\.git)?$/); const repoPath = match ? match[1] : "brandonpayton/kandelo"; const fullRev = execSync("git rev-parse HEAD", { cwd: repoRoot, @@ -348,7 +354,8 @@ function injectCorsProxyUrl(): Plugin { name: "inject-cors-proxy-url", configResolved(config) { base = config.base; - servedCorsProxyUrl = configuredCorsProxyUrl() || devCorsProxyFetchUrlForBase(base); + servedCorsProxyUrl = + configuredCorsProxyUrl() || devCorsProxyFetchUrlForBase(base); outputCorsProxyUrl = buildCorsProxyUrl(); }, configureServer(server) { @@ -464,6 +471,7 @@ const demoInputs = { ...defaultDemoInputs, "sqlite-test": path.resolve(__dirname, "pages/sqlite-test/index.html"), benchmark: path.resolve(__dirname, "pages/benchmark/index.html"), + "php-test": path.resolve(__dirname, "pages/php-test/index.html"), // The perl, python, ruby, erlang, texlive, and redis package entries // are not bundled into this static build while their slow builds // live in kandelo-software. The root gallery fetches that @@ -546,5 +554,11 @@ export default defineConfig({ dropWorkerEntryExports(), ], }, - assetsInclude: ["**/*.wasm", "**/*.sql", "**/*.vfs", "**/*.vfs.zst", "**/*.zip"], + assetsInclude: [ + "**/*.wasm", + "**/*.sql", + "**/*.vfs", + "**/*.vfs.zst", + "**/*.zip", + ], }); diff --git a/benchmarks/artifact-evidence.ts b/benchmarks/artifact-evidence.ts new file mode 100644 index 0000000000..de3fa842c8 --- /dev/null +++ b/benchmarks/artifact-evidence.ts @@ -0,0 +1,21 @@ +import type { BenchmarkArtifacts } from "./types.js"; + +export function assertRequiredBenchmarkArtifacts(artifacts: BenchmarkArtifacts): void { + const missing: string[] = []; + for (const [name, artifact] of Object.entries(artifacts.files)) { + if (artifact.required === true && artifact.missing === true && artifact.used !== false) { + missing.push(`${name} (${artifact.path})`); + } + } + for (const [name, artifact] of Object.entries(artifacts.directories ?? {})) { + if (artifact.required === true && artifact.missing === true && artifact.used !== false) { + missing.push(`${name} (${artifact.path})`); + } + } + if (missing.length > 0) { + throw new Error( + "Cannot run benchmark without required selected artifact evidence:\n" + + missing.map((name) => ` ${name}`).join("\n"), + ); + } +} diff --git a/benchmarks/artifact-selection.test.ts b/benchmarks/artifact-selection.test.ts new file mode 100644 index 0000000000..b931717fbb --- /dev/null +++ b/benchmarks/artifact-selection.test.ts @@ -0,0 +1,231 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { + BENCHMARK_STATIC_ARTIFACTS, + RUNNABLE_BENCHMARK_SUITES, + benchmarkInputEvidenceFlags, + benchmarkStaticArtifactEvidenceFlags, + selectBrowserBenchmarkRuntimeArtifacts, + selectNodeBenchmarkRuntimeArtifacts, +} from "./artifact-selection.js"; +import { assertRequiredBenchmarkArtifacts } from "./artifact-evidence.js"; +import type { BenchmarkArtifacts } from "./types.js"; +import { resolveRootfsArtifact } from "../host/src/node-kernel-host.js"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +test("Node selection follows the kernel resolver and rootfs fallback order", () => { + const rootfsRequests: string[] = []; + const rootfs = resolveRootfsArtifact((request) => { + rootfsRequests.push(request); + if (request === "rootfs.vfs") throw new Error("canonical rootfs absent"); + if (request === "programs/rootfs.vfs") return "/selected/package-rootfs.vfs"; + throw new Error(`unexpected request: ${request}`); + }); + const kernelRequests: string[] = []; + const selections = selectNodeBenchmarkRuntimeArtifacts({ + resolveOptional(request) { + kernelRequests.push(request); + return "/selected/fetched-kernel.wasm"; + }, + resolveRootfs: () => rootfs, + }); + + assert.deepEqual(kernelRequests, ["kernel.wasm"]); + assert.deepEqual(rootfsRequests, [ + "rootfs.vfs", + "programs/rootfs.vfs", + ]); + assert.equal(selections.kernel.selectedPath, "/selected/fetched-kernel.wasm"); + assert.equal(selections.kernel.resolverRequest, "kernel.wasm"); + assert.equal(selections.rootfs?.selectedPath, "/selected/package-rootfs.vfs"); + assert.equal(selections.rootfs?.resolverRequest, "programs/rootfs.vfs"); +}); + +test("browser selection uses the same policy-aware kernel resolver as Vite", () => { + const resolverRequests: string[] = []; + const selections = selectBrowserBenchmarkRuntimeArtifacts({ + resolveOptional(request) { + resolverRequests.push(request); + return "/selected/resolver-kernel.wasm"; + }, + }); + + assert.deepEqual(resolverRequests, ["kernel.wasm"]); + assert.equal(selections.kernel.selectedPath, "/selected/resolver-kernel.wasm"); + assert.equal(selections.rootfs, undefined); +}); + +test("runtime and static evidence are required only for workloads that consume them", () => { + assert.deepEqual( + benchmarkInputEvidenceFlags({ + host: "browser", + suiteFilter: "wordpress", + suites: RUNNABLE_BENCHMARK_SUITES, + }), + { required: true, used: true }, + ); + assert.deepEqual( + benchmarkInputEvidenceFlags({ + host: "node", + suiteFilter: "erlang-ring", + suites: RUNNABLE_BENCHMARK_SUITES, + }), + { required: false, used: false }, + ); + assert.deepEqual( + benchmarkInputEvidenceFlags({ + host: "node", + suites: ["syscall-io", "process-lifecycle"], + hosts: ["node"], + }), + { required: true, used: true }, + ); + assert.deepEqual( + benchmarkInputEvidenceFlags({ + host: "node", + suiteFilter: "wordpress", + suites: ["syscall-io", "process-lifecycle"], + hosts: ["node"], + }), + { required: false, used: false }, + ); + assert.deepEqual( + benchmarkInputEvidenceFlags({ + host: "node", + suiteFilter: "process-lifecycle", + suites: ["process-lifecycle"], + hosts: ["node"], + }), + { required: true, used: true }, + ); + assert.deepEqual( + benchmarkInputEvidenceFlags({ + host: "browser", + suiteFilter: "process-lifecycle", + suites: ["process-lifecycle"], + hosts: ["node"], + }), + { required: false, used: false }, + ); +}); + +test("static Wasm evidence follows the selected suite and host", () => { + const usedPaths = ( + host: "node" | "browser", + suiteFilter: string, + ): string[] => BENCHMARK_STATIC_ARTIFACTS + .filter((artifact) => benchmarkStaticArtifactEvidenceFlags({ + host, + suiteFilter, + artifact, + }).used) + .map((artifact) => artifact.path); + + assert.deepEqual(usedPaths("node", "syscall-io"), [ + "benchmarks/wasm/pipe-throughput.wasm", + "benchmarks/wasm/file-throughput.wasm", + "benchmarks/wasm/syscall-latency.wasm", + ]); + assert.deepEqual(usedPaths("node", "process-lifecycle"), [ + "benchmarks/wasm/hello.wasm", + "benchmarks/wasm/fork-bench.wasm", + "benchmarks/wasm/exec-bench.wasm", + "benchmarks/wasm/clone-bench.wasm", + "benchmarks/wasm/spawn-bench.wasm", + ]); + assert.deepEqual(usedPaths("browser", "process-lifecycle"), [ + "benchmarks/wasm/pipe-throughput.wasm", + "benchmarks/wasm/file-throughput.wasm", + "benchmarks/wasm/syscall-latency.wasm", + "benchmarks/wasm/hello.wasm", + "benchmarks/wasm/fork-bench.wasm", + "benchmarks/wasm/clone-bench.wasm", + "benchmarks/wasm/spawn-bench.wasm", + ]); + assert.deepEqual( + usedPaths("browser", "wordpress"), + usedPaths("browser", "process-lifecycle"), + ); + assert.deepEqual(usedPaths("node", "wordpress"), []); +}); + +test("browser static evidence matches the benchmark page's top-level Wasm imports", () => { + const pageSource = readFileSync( + resolve(__dirname, "../apps/browser-demos/pages/benchmark/main.ts"), + "utf8", + ); + const topLevelImports = Array.from(pageSource.matchAll( + /from "\.\.\/\.\.\/\.\.\/\.\.\/benchmarks\/wasm\/([^"?]+)\?url"/g, + )) + .map((match) => `benchmarks/wasm/${match[1]}`) + .sort(); + const browserEvidence = BENCHMARK_STATIC_ARTIFACTS + .filter((artifact) => artifact.hosts?.includes("browser") ?? true) + .map((artifact) => artifact.path) + .sort(); + + assert.deepEqual(browserEvidence, topLevelImports); +}); + +function emptyArtifacts(): BenchmarkArtifacts { + return { + gitHead: "head", + gitRef: "ref", + files: {}, + forkBench: { + hasWpkForkSymbols: false, + hasLegacyForkSymbols: false, + matchedSymbols: [], + expected: "wpk_fork_without_legacy", + passed: null, + }, + }; +} + +test("required missing evidence stops the runner unless it is explicitly unused", () => { + const artifacts = emptyArtifacts(); + artifacts.files["runtime.kernel"] = { + path: "kernel.wasm", + missing: true, + required: true, + used: true, + }; + artifacts.files["optional.opcache"] = { + path: "opcache.so", + missing: true, + required: false, + }; + artifacts.files["unused.input"] = { + path: "unused.wasm", + missing: true, + required: true, + used: false, + }; + artifacts.directories = { + "node.wordpress.sourceTree": { + path: "wordpress", + missing: true, + required: true, + }, + }; + + assert.throws( + () => assertRequiredBenchmarkArtifacts(artifacts), + (error: unknown) => { + assert(error instanceof Error); + assert.match(error.message, /runtime\.kernel/); + assert.match(error.message, /node\.wordpress\.sourceTree/); + assert.doesNotMatch(error.message, /optional\.opcache/); + assert.doesNotMatch(error.message, /unused\.input/); + return true; + }, + ); + + artifacts.files["runtime.kernel"].missing = false; + artifacts.directories["node.wordpress.sourceTree"].missing = false; + assert.doesNotThrow(() => assertRequiredBenchmarkArtifacts(artifacts)); +}); diff --git a/benchmarks/artifact-selection.ts b/benchmarks/artifact-selection.ts new file mode 100644 index 0000000000..fc299b33e9 --- /dev/null +++ b/benchmarks/artifact-selection.ts @@ -0,0 +1,169 @@ +import { + tryResolveBinary, +} from "../host/src/binary-resolver.js"; +import { + resolveRootfsArtifact, + type ResolvedRootfsArtifact, +} from "../host/src/node-kernel-host.js"; + +export interface BenchmarkRuntimeArtifactSelection { + logicalPath: string; + selectedPath: string | null; + resolverRequest?: string; + resolverSelectedPath?: string | null; + error?: string; +} + +export interface BenchmarkRuntimeArtifactSelections { + kernel: BenchmarkRuntimeArtifactSelection; + rootfs?: BenchmarkRuntimeArtifactSelection; +} + +export interface BenchmarkStaticArtifactSelection { + path: string; + suites: string[]; + hosts?: Array<"node" | "browser">; +} + +export const BENCHMARK_STATIC_ARTIFACTS: BenchmarkStaticArtifactSelection[] = [ + { + path: "benchmarks/wasm/pipe-throughput.wasm", + suites: ["syscall-io"], + }, + { + path: "benchmarks/wasm/file-throughput.wasm", + suites: ["syscall-io"], + }, + { + path: "benchmarks/wasm/syscall-latency.wasm", + suites: ["syscall-io"], + }, + { + path: "benchmarks/wasm/hello.wasm", + suites: ["process-lifecycle"], + }, + { + path: "benchmarks/wasm/fork-bench.wasm", + suites: ["process-lifecycle"], + }, + { + path: "benchmarks/wasm/exec-bench.wasm", + suites: ["process-lifecycle"], + hosts: ["node"], + }, + { + path: "benchmarks/wasm/clone-bench.wasm", + suites: ["process-lifecycle"], + }, + { + path: "benchmarks/wasm/spawn-bench.wasm", + suites: ["process-lifecycle"], + }, +]; + +export const RUNNABLE_BENCHMARK_SUITES = [ + "syscall-io", + "process-lifecycle", + "wordpress", + "mariadb-aria", + "mariadb-aria-64", + "mariadb-innodb", + "mariadb-innodb-64", +]; + +type OptionalResolver = (request: string) => string | null; + +export function benchmarkInputEvidenceFlags(options: { + host: "node" | "browser"; + suiteFilter?: string; + suites: string[]; + hosts?: ReadonlyArray<"node" | "browser">; +}): { required: boolean; used: boolean } { + const hosts = options.hosts ?? ["node", "browser"]; + const used = hosts.includes(options.host) && ( + options.suiteFilter === undefined || options.suites.includes(options.suiteFilter) + ); + return { required: used, used }; +} + +export function benchmarkStaticArtifactEvidenceFlags(options: { + host: "node" | "browser"; + suiteFilter?: string; + artifact: BenchmarkStaticArtifactSelection; +}): { required: boolean; used: boolean } { + return benchmarkInputEvidenceFlags({ + host: options.host, + suiteFilter: options.suiteFilter, + // The browser benchmark page imports every browser-compatible micro Wasm + // URL at module load, before a suite is selected. Node loads by suite. + suites: options.host === "browser" + ? RUNNABLE_BENCHMARK_SUITES + : options.artifact.suites, + hosts: options.artifact.hosts, + }); +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function selectNodeBenchmarkRuntimeArtifacts( + options: { + resolveOptional?: OptionalResolver; + resolveRootfs?: () => ResolvedRootfsArtifact; + } = {}, +): BenchmarkRuntimeArtifactSelections { + const resolveOptional = options.resolveOptional ?? tryResolveBinary; + const resolveRootfs = options.resolveRootfs ?? resolveRootfsArtifact; + const kernelPath = resolveOptional("kernel.wasm"); + + let rootfs: BenchmarkRuntimeArtifactSelection; + try { + const resolved = resolveRootfs(); + rootfs = { + logicalPath: "rootfs.vfs", + selectedPath: resolved.selectedPath, + resolverRequest: resolved.resolverRequest, + resolverSelectedPath: resolved.selectedPath, + }; + } catch (error) { + rootfs = { + logicalPath: "rootfs.vfs", + selectedPath: null, + resolverRequest: "rootfs.vfs -> programs/rootfs.vfs", + error: errorMessage(error), + }; + } + + return { + kernel: { + logicalPath: "kernel.wasm", + selectedPath: kernelPath, + resolverRequest: "kernel.wasm", + resolverSelectedPath: kernelPath, + ...(!kernelPath + ? { error: "tryResolveBinary(\"kernel.wasm\") returned no usable artifact" } + : {}), + }, + rootfs, + }; +} + +export function selectBrowserBenchmarkRuntimeArtifacts(options: { + resolveOptional?: OptionalResolver; +} = {}): BenchmarkRuntimeArtifactSelections { + const resolveOptional = options.resolveOptional ?? tryResolveBinary; + const kernelPath = resolveOptional("kernel.wasm"); + + return { + kernel: { + logicalPath: "@kernel-wasm", + selectedPath: kernelPath, + resolverRequest: "kernel.wasm", + resolverSelectedPath: kernelPath, + ...(!kernelPath + ? { error: "tryResolveBinary(\"kernel.wasm\") returned no usable artifact" } + : {}), + }, + }; +} diff --git a/benchmarks/browser/run-browser.ts b/benchmarks/browser/run-browser.ts index eb3c2eec06..8eaea5db51 100644 --- a/benchmarks/browser/run-browser.ts +++ b/benchmarks/browser/run-browser.ts @@ -12,10 +12,26 @@ import { copyFileSync, existsSync, mkdirSync } from "fs"; import { resolve, dirname } from "path"; import { fileURLToPath } from "url"; import { tryResolveBinary } from "../../host/src/binary-resolver.js"; +import { + BENCHMARK_STATIC_ARTIFACTS, + benchmarkStaticArtifactEvidenceFlags, + selectBrowserBenchmarkRuntimeArtifacts, + type BenchmarkRuntimeArtifactSelections, +} from "../artifact-selection.js"; +import { withRejectingTimeout } from "../timeout.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const browserDir = resolve(__dirname, "../../apps/browser-demos"); +declare global { + interface Window { + __runBenchmark: ( + suiteName: string, + options?: { rounds?: number }, + ) => Promise>; + } +} + /** Suites available in the browser benchmark page. */ const BROWSER_SUITES = [ "syscall-io", "process-lifecycle", "wordpress", @@ -38,85 +54,109 @@ const SUITE_TIMEOUTS: Record = { "mariadb-innodb-64": 600_000, }; -const REQUIRED_BROWSER_ARTIFACTS = [ - { - label: "kernel.wasm", - paths: ["local-binaries/kernel.wasm", "binaries/kernel.wasm"], - buildHint: "bash build.sh", - }, - { - label: "rootfs.vfs", - paths: ["host/wasm/rootfs.vfs"], - buildHint: "bash build.sh", - }, - { - label: "browser benchmark wasm programs", - paths: [ - "benchmarks/wasm/pipe-throughput.wasm", - "benchmarks/wasm/file-throughput.wasm", - "benchmarks/wasm/syscall-latency.wasm", - "benchmarks/wasm/hello.wasm", - "benchmarks/wasm/fork-bench.wasm", - "benchmarks/wasm/clone-bench.wasm", - "benchmarks/wasm/spawn-bench.wasm", - ], - buildHint: "scripts/build-programs.sh", - all: true, - }, -]; - -function hasAny(paths: string[]): boolean { - return paths.some((path) => existsSync(resolve(browserDir, "../..", path))); -} - -function hasAll(paths: string[]): boolean { - return paths.every((path) => existsSync(resolve(browserDir, "../..", path))); -} +function assertBrowserArtifactsAvailable( + runtimeSelections: BenchmarkRuntimeArtifactSelections, + suiteNames: string[], +): void { + const kernel = runtimeSelections.kernel; + if (!kernel.selectedPath || !existsSync(kernel.selectedPath)) { + throw new Error( + "Browser benchmark prerequisite is missing: kernel.wasm.\n" + + "The @kernel-wasm policy resolver found no usable artifact.\n" + + "Run: bash build.sh", + ); + } -function assertBrowserArtifactsAvailable(): void { - for (const artifact of REQUIRED_BROWSER_ARTIFACTS) { - const found = artifact.all ? hasAll(artifact.paths) : hasAny(artifact.paths); - if (!found) { - const checked = artifact.paths - .map((path) => ` ${resolve(browserDir, "../..", path)}`) - .join("\n"); + for (const artifact of BENCHMARK_STATIC_ARTIFACTS) { + const used = benchmarkStaticArtifactEvidenceFlags({ + host: "browser", + suiteFilter: suiteNames.length === 1 ? suiteNames[0] : undefined, + artifact, + }).used; + if (used) { + const selectedPath = resolve(browserDir, "../..", artifact.path); + if (existsSync(selectedPath)) continue; throw new Error( - `Browser benchmark prerequisite is missing: ${artifact.label}.\n` + - `Run: ${artifact.buildHint}\n` + - `Checked:\n${checked}`, + `Browser benchmark prerequisite is missing: ${artifact.path}.\n` + + "Run: scripts/build-programs.sh", ); } } } -function materializePublicAsset(relBinaryPath: string, publicName: string): void { +export interface BrowserBenchmarkAssetSelection { + publicPath: string; + resolverRequest: string; + resolverSelectedPath: string | null; + selectedPath: string | null; +} + +function materializePublicAsset( + relBinaryPath: string, + publicName: string, +): BrowserBenchmarkAssetSelection { const publicPath = resolve(browserDir, "public", publicName); - if (existsSync(publicPath)) return; + if (existsSync(publicPath)) { + return { + publicPath, + resolverRequest: relBinaryPath, + resolverSelectedPath: null, + selectedPath: publicPath, + }; + } const sourcePath = tryResolveBinary(relBinaryPath); - if (!sourcePath || !existsSync(sourcePath)) return; + if (!sourcePath || !existsSync(sourcePath)) { + return { + publicPath, + resolverRequest: relBinaryPath, + resolverSelectedPath: sourcePath, + selectedPath: null, + }; + } mkdirSync(dirname(publicPath), { recursive: true }); copyFileSync(sourcePath, publicPath); + return { + publicPath, + resolverRequest: relBinaryPath, + resolverSelectedPath: sourcePath, + selectedPath: publicPath, + }; } -function materializeBrowserSuiteAssets(suiteNames: string[]): void { +export function prepareBrowserBenchmarkAssets( + suiteNames: string[] = BROWSER_SUITES, +): Record { + const selections: Record = {}; if (suiteNames.includes("wordpress")) { - materializePublicAsset("programs/wordpress.vfs.zst", "wordpress.vfs.zst"); + selections["browser.wordpress.vfs"] = materializePublicAsset( + "programs/wordpress.vfs.zst", + "wordpress.vfs.zst", + ); } if (suiteNames.some((suite) => suite === "mariadb-aria" || suite === "mariadb-innodb")) { - materializePublicAsset("programs/mariadb-vfs.vfs.zst", "mariadb.vfs.zst"); + selections["browser.mariadb.wasm32.vfs"] = materializePublicAsset( + "programs/mariadb-vfs.vfs.zst", + "mariadb.vfs.zst", + ); } if (suiteNames.some((suite) => suite === "mariadb-aria-64" || suite === "mariadb-innodb-64")) { - materializePublicAsset("programs/wasm64/mariadb-vfs.vfs.zst", "mariadb-64.vfs.zst"); + selections["browser.mariadb.wasm64.vfs"] = materializePublicAsset( + "programs/wasm64/mariadb-vfs.vfs.zst", + "mariadb-64.vfs.zst", + ); } + + return selections; } export interface BrowserBenchmarkOptions { suites?: string[]; rounds?: number; + runtimeSelections?: BenchmarkRuntimeArtifactSelections; } function median(values: number[]): number { @@ -155,8 +195,10 @@ export async function runBrowserBenchmarks( return results; } - assertBrowserArtifactsAvailable(); - materializeBrowserSuiteAssets(suiteNames); + const runtimeSelections = options.runtimeSelections ?? + selectBrowserBenchmarkRuntimeArtifacts(); + assertBrowserArtifactsAvailable(runtimeSelections, suiteNames); + prepareBrowserBenchmarkAssets(suiteNames); // Start Vite dev server console.log("Starting Vite dev server..."); @@ -219,10 +261,14 @@ export async function runBrowserBenchmarks( for (let r = 0; r < rounds; r++) { console.log(` round ${r + 1}/${rounds}...`); const timeout = SUITE_TIMEOUTS[suiteName] ?? 120_000; - page.setDefaultTimeout(timeout); - const metrics = await page.evaluate( - ([name, opts]) => window.__runBenchmark(name, opts), - [suiteName, { rounds: 1 }] as const, + const metrics = await withRejectingTimeout( + page.evaluate( + ([name, opts]) => window.__runBenchmark(name, opts), + [suiteName, { rounds: 1 }] as const, + ), + timeout, + `Browser benchmark ${suiteName} round ${r + 1}/${rounds} ` + + `timed out after ${timeout}ms`, ); for (const [key, value] of Object.entries(metrics)) { diff --git a/benchmarks/run.ts b/benchmarks/run.ts index d9004f9f89..8393c4bf8b 100644 --- a/benchmarks/run.ts +++ b/benchmarks/run.ts @@ -8,35 +8,44 @@ * npx tsx benchmarks/run.ts --suite=wordpress # Single suite * npx tsx benchmarks/run.ts --rounds=5 # Multiple rounds (median) */ -import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "fs"; -import { resolve, dirname } from "path"; -import { fileURLToPath } from "url"; +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readdirSync, + realpathSync, + statSync, + writeFileSync, +} from "fs"; +import { resolve, dirname, relative, sep } from "path"; +import { fileURLToPath, pathToFileURL } from "url"; import { execFileSync } from "child_process"; import { createHash } from "crypto"; import os from "os"; import type { BenchmarkArtifactFile, + BenchmarkArtifactDirectory, BenchmarkArtifacts, BenchmarkOutput, ForkBenchSymbolReport, BenchmarkSuite, } from "./types.js"; +import type { BrowserBenchmarkAssetSelection } from "./browser/run-browser.js"; +import { + BENCHMARK_STATIC_ARTIFACTS, + RUNNABLE_BENCHMARK_SUITES, + benchmarkInputEvidenceFlags, + benchmarkStaticArtifactEvidenceFlags, + selectBrowserBenchmarkRuntimeArtifacts, + selectNodeBenchmarkRuntimeArtifacts, + type BenchmarkRuntimeArtifactSelections, +} from "./artifact-selection.js"; +import { assertRequiredBenchmarkArtifacts } from "./artifact-evidence.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, ".."); -const BENCHMARK_ARTIFACT_PATHS = [ - "local-binaries/kernel.wasm", - "benchmarks/wasm/pipe-throughput.wasm", - "benchmarks/wasm/file-throughput.wasm", - "benchmarks/wasm/syscall-latency.wasm", - "benchmarks/wasm/hello.wasm", - "benchmarks/wasm/fork-bench.wasm", - "benchmarks/wasm/exec-bench.wasm", - "benchmarks/wasm/clone-bench.wasm", - "benchmarks/wasm/spawn-bench.wasm", -]; - function parseArgs(argv: string[]): { host: "node" | "browser"; suite?: string; rounds: number } { let host: "node" | "browser" = "node"; let suite: string | undefined; @@ -110,18 +119,154 @@ function runGit(args: string[]): string { } } -function fingerprintFile(relativePath: string): BenchmarkArtifactFile { - const absolutePath = resolve(repoRoot, relativePath); - if (!existsSync(absolutePath)) { - return { path: relativePath, missing: true }; +type ArtifactFileMetadata = Omit< + Partial, + "path" | "selectedPath" | "sizeBytes" | "sha256" | "missing" +>; + +function repoRelativePath(path: string): string { + const rel = relative(repoRoot, path); + if (rel !== "" && rel !== ".." && !rel.startsWith(`..${sep}`)) { + return rel.split(sep).join("/"); } + return path; +} - const bytes = readFileSync(absolutePath); - return { - path: relativePath, - sizeBytes: statSync(absolutePath).size, - sha256: createHash("sha256").update(bytes).digest("hex"), +function fingerprintSelectedFile( + logicalPath: string, + selectedPath: string | null, + metadata: ArtifactFileMetadata = {}, +): BenchmarkArtifactFile { + if (!selectedPath || !existsSync(selectedPath)) { + return { + path: logicalPath, + ...(selectedPath ? { selectedPath } : {}), + ...metadata, + missing: true, + }; + } + + try { + const bytes = readFileSync(selectedPath); + return { + path: logicalPath, + selectedPath, + ...metadata, + sizeBytes: statSync(selectedPath).size, + sha256: createHash("sha256").update(bytes).digest("hex"), + }; + } catch (error) { + return { + path: logicalPath, + selectedPath, + ...metadata, + missing: true, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +function fingerprintDirectory( + logicalPath: string, + selectedPath: string, + excludedPaths: string[], +): BenchmarkArtifactDirectory { + const exclusions = [...excludedPaths].map((path) => path.replaceAll("\\", "/")).sort(); + if (!existsSync(selectedPath)) { + return { + path: logicalPath, + selectedPath, + missing: true, + required: true, + excludedPaths: exclusions, + }; + } + + const hash = createHash("sha256"); + let fileCount = 0; + let sizeBytes = 0; + const activeDirectories = new Set(); + + const excluded = (logicalEntry: string): boolean => exclusions.some( + (entry) => logicalEntry === entry || logicalEntry.startsWith(`${entry}/`), + ); + const compareNames = (a: string, b: string): number => a < b ? -1 : a > b ? 1 : 0; + + const hashFile = (physicalPath: string, logicalEntry: string, kind: string): void => { + const bytes = readFileSync(physicalPath); + hash.update(`${kind}\0${logicalEntry}\0${bytes.length}\0`); + hash.update(bytes); + hash.update("\0"); + fileCount++; + sizeBytes += bytes.length; }; + + const walk = (physicalDirectory: string, logicalDirectory: string): void => { + const realDirectory = realpathSync(physicalDirectory); + if (activeDirectories.has(realDirectory)) { + hash.update(`cycle\0${logicalDirectory}\0`); + return; + } + + activeDirectories.add(realDirectory); + try { + const entries = readdirSync(physicalDirectory, { withFileTypes: true }) + .sort((a, b) => compareNames(a.name, b.name)); + for (const entry of entries) { + const logicalEntry = logicalDirectory + ? `${logicalDirectory}/${entry.name}` + : entry.name; + if (excluded(logicalEntry)) continue; + + const physicalEntry = resolve(physicalDirectory, entry.name); + const stat = lstatSync(physicalEntry); + if (stat.isSymbolicLink()) { + const realEntry = realpathSync(physicalEntry); + const targetStat = statSync(realEntry); + if (targetStat.isDirectory()) { + hash.update(`symlink-directory\0${logicalEntry}\0`); + walk(realEntry, logicalEntry); + } else if (targetStat.isFile()) { + hashFile(realEntry, logicalEntry, "symlink-file"); + } else { + hash.update(`symlink-other\0${logicalEntry}\0`); + } + } else if (stat.isDirectory()) { + hash.update(`directory\0${logicalEntry}\0`); + walk(physicalEntry, logicalEntry); + } else if (stat.isFile()) { + hashFile(physicalEntry, logicalEntry, "file"); + } else { + hash.update(`other\0${logicalEntry}\0`); + } + } + } finally { + activeDirectories.delete(realDirectory); + } + }; + + try { + hash.update("kandelo-benchmark-directory-v1\0"); + walk(selectedPath, ""); + return { + path: logicalPath, + selectedPath, + fileCount, + sizeBytes, + sha256: hash.digest("hex"), + required: true, + excludedPaths: exclusions, + }; + } catch (error) { + return { + path: logicalPath, + selectedPath, + missing: true, + required: true, + excludedPaths: exclusions, + error: error instanceof Error ? error.message : String(error), + }; + } } function expectedForkBenchSymbols(_gitHead: string): ForkBenchSymbolReport["expected"] { @@ -160,17 +305,222 @@ function inspectForkBench(gitHead: string): ForkBenchSymbolReport { }; } -function collectArtifacts(): BenchmarkArtifacts { +function suiteRequested(filter: string | undefined, names: string[]): boolean { + return filter === undefined || names.includes(filter); +} + +async function collectNodeApplicationArtifacts( + suiteFilter: string | undefined, + files: Record, + directories: Record, +): Promise { + if (suiteRequested(suiteFilter, ["wordpress"])) { + try { + const { describeWordPressBenchmarkInputs } = await import("./suites/wordpress.js"); + const inputs = describeWordPressBenchmarkInputs(); + files["node.wordpress.php"] = fingerprintSelectedFile( + inputs.phpResolverRequest, + inputs.phpBinaryPath, + { + required: true, + used: true, + resolverRequest: inputs.phpResolverRequest, + resolverSelectedPath: inputs.phpResolverSelectedPath ?? undefined, + }, + ); + files["node.wordpress.opcache"] = fingerprintSelectedFile( + inputs.opcacheResolverRequest, + inputs.opcachePath, + { + required: false, + used: inputs.opcacheUsed, + resolverRequest: inputs.opcacheResolverRequest, + resolverSelectedPath: inputs.opcachePath ?? undefined, + }, + ); + files["node.wordpress.config"] = fingerprintSelectedFile( + repoRelativePath(inputs.wpConfigPath), + inputs.wpConfigPath, + { required: true, used: true }, + ); + files["node.wordpress.router"] = fingerprintSelectedFile( + repoRelativePath(inputs.routerScript), + inputs.routerScript, + { required: true, used: true }, + ); + directories["node.wordpress.sourceTree"] = fingerprintDirectory( + repoRelativePath(inputs.wpDir), + inputs.wpDir, + [ + "wp-content/database", + "wp-content/debug.log", + ], + ); + } catch (error) { + files["node.wordpress.inputs"] = { + path: "node WordPress benchmark inputs", + missing: true, + required: true, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + const mariaArches = [ + { + arch: "wasm32" as const, + suites: ["mariadb-aria", "mariadb-innodb"], + }, + { + arch: "wasm64" as const, + suites: ["mariadb-aria-64", "mariadb-innodb-64"], + }, + ]; + for (const { arch, suites } of mariaArches) { + if (!suiteRequested(suiteFilter, suites)) continue; + + try { + const { describeMariaDBBenchmarkInputs } = await import("./suites/mariadb.js"); + const inputs = describeMariaDBBenchmarkInputs(arch); + files[`node.mariadb.${arch}.server`] = fingerprintSelectedFile( + inputs.serverResolverRequest, + inputs.serverPath, + { + required: true, + used: true, + resolverRequest: inputs.serverResolverRequest, + resolverSelectedPath: inputs.serverResolverSelectedPath ?? undefined, + }, + ); + files[`node.mariadb.${arch}.client`] = fingerprintSelectedFile( + inputs.clientResolverRequest, + inputs.clientPath, + { + required: true, + used: true, + resolverRequest: inputs.clientResolverRequest, + resolverSelectedPath: inputs.clientResolverSelectedPath ?? undefined, + }, + ); + + const sqlError = inputs.bootstrapSqlError; + files[`node.mariadb.${arch}.bootstrap.systemTables`] = fingerprintSelectedFile( + inputs.bootstrapSql + ? repoRelativePath(inputs.bootstrapSql.systemTables) + : `${arch} MariaDB system-tables bootstrap SQL`, + inputs.bootstrapSql?.systemTables ?? null, + { required: true, used: true, ...(sqlError ? { error: sqlError } : {}) }, + ); + files[`node.mariadb.${arch}.bootstrap.systemData`] = fingerprintSelectedFile( + inputs.bootstrapSql + ? repoRelativePath(inputs.bootstrapSql.systemData) + : `${arch} MariaDB system-data bootstrap SQL`, + inputs.bootstrapSql?.systemData ?? null, + { required: true, used: true, ...(sqlError ? { error: sqlError } : {}) }, + ); + } catch (error) { + files[`node.mariadb.${arch}.inputs`] = { + path: `node MariaDB ${arch} benchmark inputs`, + missing: true, + required: true, + error: error instanceof Error ? error.message : String(error), + }; + } + } +} + +function collectBrowserApplicationArtifacts( + selections: Record, + files: Record, +): void { + for (const [name, selection] of Object.entries(selections)) { + files[name] = fingerprintSelectedFile( + repoRelativePath(selection.publicPath), + selection.selectedPath, + { + required: true, + used: true, + resolverRequest: selection.resolverRequest, + resolverSelectedPath: selection.resolverSelectedPath ?? undefined, + }, + ); + } +} + +function collectRuntimeArtifacts( + selections: BenchmarkRuntimeArtifactSelections, + files: Record, + options: { host: "node" | "browser"; suiteFilter?: string }, +): void { + for (const [name, selection] of Object.entries(selections)) { + const evidenceFlags = benchmarkInputEvidenceFlags({ + ...options, + suites: name === "rootfs" + ? ["syscall-io", "process-lifecycle"] + : RUNNABLE_BENCHMARK_SUITES, + ...(name === "rootfs" ? { hosts: ["node" as const] } : {}), + }); + files[`runtime.${name}`] = fingerprintSelectedFile( + selection.logicalPath, + selection.selectedPath, + { + ...evidenceFlags, + ...(selection.resolverRequest + ? { resolverRequest: selection.resolverRequest } + : {}), + ...(selection.resolverSelectedPath + ? { resolverSelectedPath: selection.resolverSelectedPath } + : {}), + ...(selection.error ? { error: selection.error } : {}), + }, + ); + } +} + +export async function collectBenchmarkArtifacts(options: { + host: "node" | "browser"; + suiteFilter?: string; + browserSelections?: Record; + runtimeSelections?: BenchmarkRuntimeArtifactSelections; +}): Promise { const gitHead = runGit(["rev-parse", "HEAD"]); const gitRef = runGit(["name-rev", "--name-only", "--refs=refs/remotes/origin/*", "HEAD"]); - const files = Object.fromEntries( - BENCHMARK_ARTIFACT_PATHS.map((path) => [path, fingerprintFile(path)]), + const files: Record = Object.fromEntries( + BENCHMARK_STATIC_ARTIFACTS.map((artifact) => { + const evidenceFlags = benchmarkStaticArtifactEvidenceFlags({ + host: options.host, + suiteFilter: options.suiteFilter, + artifact, + }); + return [ + artifact.path, + fingerprintSelectedFile( + artifact.path, + resolve(repoRoot, artifact.path), + evidenceFlags, + ), + ]; + }), + ); + const directories: Record = {}; + const runtimeSelections = options.runtimeSelections ?? ( + options.host === "node" + ? selectNodeBenchmarkRuntimeArtifacts() + : selectBrowserBenchmarkRuntimeArtifacts() ); + collectRuntimeArtifacts(runtimeSelections, files, options); + + if (options.host === "node") { + await collectNodeApplicationArtifacts(options.suiteFilter, files, directories); + } else if (options.browserSelections) { + collectBrowserApplicationArtifacts(options.browserSelections, files); + } return { gitHead, gitRef, files, + ...(Object.keys(directories).length > 0 ? { directories } : {}), forkBench: inspectForkBench(gitHead), }; } @@ -178,11 +528,37 @@ function collectArtifacts(): BenchmarkArtifacts { function logArtifacts(artifacts: BenchmarkArtifacts) { console.log("\nArtifact fingerprints:"); console.log(` git HEAD: ${artifacts.gitHead} (${artifacts.gitRef})`); - for (const artifact of Object.values(artifacts.files)) { + for (const [name, artifact] of Object.entries(artifacts.files)) { + const label = name === artifact.path ? artifact.path : `${name} (${artifact.path})`; + const selected = artifact.selectedPath ? ` selected=${artifact.selectedPath}` : ""; + const resolver = artifact.resolverSelectedPath + ? ` resolver-selected=${artifact.resolverSelectedPath}` + : ""; + const usage = artifact.used === false ? " used=false" : ""; + if (artifact.missing) { + const required = artifact.required ? " REQUIRED" : ""; + console.log(` ${label}: MISSING${required}${usage}${selected}${resolver}`); + if (artifact.error) console.log(` error: ${artifact.error}`); + } else { + console.log( + ` ${label}: ${artifact.sizeBytes} bytes sha256=${artifact.sha256}` + + `${usage}${selected}${resolver}`, + ); + } + } + for (const [name, artifact] of Object.entries(artifacts.directories ?? {})) { + const label = name === artifact.path ? artifact.path : `${name} (${artifact.path})`; if (artifact.missing) { - console.log(` ${artifact.path}: MISSING`); + console.log(` ${label}: MISSING${artifact.required ? " REQUIRED" : ""}`); + if (artifact.error) console.log(` error: ${artifact.error}`); } else { - console.log(` ${artifact.path}: ${artifact.sizeBytes} bytes sha256=${artifact.sha256}`); + console.log( + ` ${label}: ${artifact.fileCount} files ${artifact.sizeBytes} bytes ` + + `sha256=${artifact.sha256} selected=${artifact.selectedPath}`, + ); + if (artifact.excludedPaths?.length) { + console.log(` excluded runtime state: ${artifact.excludedPaths.join(", ")}`); + } } } console.log( @@ -291,14 +667,29 @@ async function runSuites( async function main() { const { host, suite: suiteFilter, rounds } = parseArgs(process.argv); - const artifacts = collectArtifacts(); - logArtifacts(artifacts); if (host === "browser") { // Browser execution via Playwright - const { runBrowserBenchmarks } = await import("./browser/run-browser.js"); + const { + prepareBrowserBenchmarkAssets, + runBrowserBenchmarks, + } = await import("./browser/run-browser.js"); const suiteNames = suiteFilter ? [suiteFilter] : undefined; - const results = await runBrowserBenchmarks({ suites: suiteNames, rounds }); + const runtimeSelections = selectBrowserBenchmarkRuntimeArtifacts(); + const browserSelections = prepareBrowserBenchmarkAssets(suiteNames); + const artifacts = await collectBenchmarkArtifacts({ + host, + suiteFilter, + browserSelections, + runtimeSelections, + }); + logArtifacts(artifacts); + assertRequiredBenchmarkArtifacts(artifacts); + const results = await runBrowserBenchmarks({ + suites: suiteNames, + rounds, + runtimeSelections, + }); const output: BenchmarkOutput = { timestamp: new Date().toISOString(), @@ -321,6 +712,9 @@ async function main() { } // Node.js execution + const artifacts = await collectBenchmarkArtifacts({ host, suiteFilter }); + logArtifacts(artifacts); + assertRequiredBenchmarkArtifacts(artifacts); const suites = await loadNodeSuites(suiteFilter); if (suites.length === 0) { console.error("No suites available to run."); @@ -349,7 +743,9 @@ async function main() { console.log(`\nResults saved to ${filepath}`); } -main().catch((err) => { - console.error(err); - process.exit(1); -}); +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main().catch((err) => { + console.error(err); + process.exit(1); + }); +} diff --git a/benchmarks/suites/mariadb.ts b/benchmarks/suites/mariadb.ts index c1661a5587..2577e0f4d2 100644 --- a/benchmarks/suites/mariadb.ts +++ b/benchmarks/suites/mariadb.ts @@ -16,6 +16,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "../.."); const mariadbLibDir = resolve(repoRoot, "packages/registry/mariadb"); +const MARIADB_PROCESS_TIMEOUT_MS = 120_000; +const MARIADB_CLEANUP_TIMEOUT_MS = 10_000; export type WasmArch = "wasm32" | "wasm64"; @@ -62,6 +64,45 @@ function resolveMariaDBBootstrapSql(arch: WasmArch): { systemTables: string; sys }; } +/** Filesystem inputs selected by one Node MariaDB architecture. */ +export function describeMariaDBBenchmarkInputs(arch: WasmArch): { + serverPath: string | null; + serverResolverRequest: string; + serverResolverSelectedPath: string | null; + clientPath: string | null; + clientResolverRequest: string; + clientResolverSelectedPath: string | null; + bootstrapSql: { systemTables: string; systemData: string } | null; + bootstrapSqlError?: string; +} { + const serverResolverRequest = resolverPathFor(arch, "mariadbd.wasm"); + const clientResolverRequest = resolverPathFor(arch, "mysqltest.wasm"); + const serverPath = resolveMariaDBProgram(arch, "mariadbd.wasm"); + const clientPath = resolveMariaDBProgram(arch, "mysqltest.wasm"); + let bootstrapSql: { systemTables: string; systemData: string } | null = null; + let bootstrapSqlError: string | undefined; + if (serverPath && clientPath) { + try { + bootstrapSql = resolveMariaDBBootstrapSql(arch); + } catch (error) { + bootstrapSqlError = error instanceof Error ? error.message : String(error); + } + } else { + bootstrapSqlError = "not resolved because MariaDB program prerequisites are missing"; + } + + return { + serverPath, + serverResolverRequest, + serverResolverSelectedPath: tryResolveBinary(serverResolverRequest), + clientPath, + clientResolverRequest, + clientResolverSelectedPath: tryResolveBinary(clientResolverRequest), + bootstrapSql, + bootstrapSqlError, + }; +} + function loadBytes(path: string): ArrayBuffer { const buf = readFileSync(path); return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); @@ -79,12 +120,35 @@ function getFreePort(): Promise { }); } +async function withRejectingTimeout( + operation: Promise, + timeoutMs: number, + message: string, +): Promise { + let timeoutId: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutId = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + try { + return await Promise.race([operation, timeout]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + +function outputSuffix(output: string): string { + const trimmed = output.trim(); + return trimmed ? `:\n${trimmed}` : ""; +} + interface MariaDBInstance { host: NodeKernelHost; port: number; getOutput: () => string; getProcessOutput: (pid: number) => { stdout: string; stderr: string }; dataDir: string; + assertLiveServer: (activity: string) => void; + raceLiveServer: (operation: Promise, activity: string) => Promise; cleanup: () => Promise; } @@ -154,19 +218,129 @@ async function startMariaDB(arch: WasmArch, dataDir: string, bootstrap: boolean, stdinData = new TextEncoder().encode(bootstrapSql); } + let livePid: number | undefined; + let resolveStarted: ((pid: number) => void) | undefined; + const startedPromise = bootstrap + ? undefined + : new Promise((resolveStartedPromise) => { + resolveStarted = resolveStartedPromise; + }); const exitPromise = host.spawn(mysqldBytes, serverArgs, { env: ["HOME=/tmp", "PATH=/usr/local/bin:/usr/bin:/bin", "TMPDIR=/tmp"], cwd: dataDir, - stdin: stdinData, + // Supplying onStarted keeps stdin open by default. The live benchmark + // server instead needs the same immediate EOF it had before pid tracking. + stdin: bootstrap ? stdinData : new Uint8Array(0), + onStarted: bootstrap ? undefined : (pid) => resolveStarted?.(pid), }); if (bootstrap) { - const timeout = new Promise((r) => setTimeout(() => r(0), 120_000)); - await Promise.race([exitPromise, timeout]); + try { + const exitCode = await withRejectingTimeout( + exitPromise, + MARIADB_PROCESS_TIMEOUT_MS, + `MariaDB ${arch} bootstrap timed out after ${MARIADB_PROCESS_TIMEOUT_MS}ms`, + ); + if (exitCode !== 0) { + throw new Error( + `MariaDB ${arch} bootstrap exited with status ${exitCode}` + + outputSuffix(output), + ); + } + } catch (error) { + await host.destroy().catch(() => {}); + throw error; + } + } else { + try { + livePid = await withRejectingTimeout( + Promise.race([ + startedPromise!, + exitPromise.then((exitCode) => { + throw new Error( + `MariaDB ${arch} server exited with status ${exitCode} before startup completed` + + outputSuffix(output), + ); + }), + ]), + MARIADB_PROCESS_TIMEOUT_MS, + `MariaDB ${arch} server startup timed out after ${MARIADB_PROCESS_TIMEOUT_MS}ms`, + ); + } catch (error) { + await host.destroy().catch(() => {}); + throw error; + } + } + + let liveExitStatus: number | undefined; + let prematureExitObserved = false; + if (!bootstrap) { + void exitPromise.then( + (status) => { liveExitStatus = status; }, + () => {}, + ); } + const liveServerError = (activity: string, status: number) => new Error( + `MariaDB ${arch} server exited with status ${status} ${activity}` + + outputSuffix(output), + ); + + const assertLiveServer = (activity: string) => { + if (liveExitStatus === undefined) return; + prematureExitObserved = true; + throw liveServerError(activity, liveExitStatus); + }; + + const raceLiveServer = async (operation: Promise, activity: string): Promise => { + if (bootstrap) return operation; + assertLiveServer(activity); + return Promise.race([ + operation, + exitPromise.then((status) => { + prematureExitObserved = true; + throw liveServerError(activity, status); + }), + ]); + }; + + let cleanedUp = false; const cleanup = async () => { - await host.destroy().catch(() => {}); + if (cleanedUp) return; + cleanedUp = true; + + let cleanupError: unknown; + if (!bootstrap && livePid !== undefined) { + if (liveExitStatus === undefined) { + try { + await host.terminateProcess(livePid, 137); + } catch (error) { + cleanupError = error; + } + + try { + const exitCode = await withRejectingTimeout( + exitPromise, + MARIADB_CLEANUP_TIMEOUT_MS, + `MariaDB ${arch} server did not exit after deliberate termination`, + ); + if (exitCode !== 137) { + cleanupError = liveServerError("before deliberate cleanup completed", exitCode); + } + } catch (error) { + cleanupError ??= error; + } + } else if (!prematureExitObserved) { + cleanupError = liveServerError("before deliberate cleanup began", liveExitStatus); + } + } + + try { + await host.destroy(); + } catch (error) { + cleanupError ??= error; + } + if (cleanupError !== undefined) throw cleanupError; }; return { @@ -175,6 +349,8 @@ async function startMariaDB(arch: WasmArch, dataDir: string, bootstrap: boolean, dataDir, getOutput: () => output, getProcessOutput: (pid) => processOutput.get(pid) ?? { stdout: "", stderr: "" }, + assertLiveServer, + raceLiveServer, cleanup, }; } @@ -206,10 +382,14 @@ async function runMysqlTest( onStarted: (startedPid) => { pid = startedPid; }, }, ); - const timeout = new Promise((_, reject) => { - setTimeout(() => reject(new Error("mysqltest timed out after 120000ms")), 120_000); - }); - const exitCode = await Promise.race([exitPromise, timeout]); + const exitCode = await withRejectingTimeout( + instance.raceLiveServer( + exitPromise, + "during a mysqltest operation", + ), + MARIADB_PROCESS_TIMEOUT_MS, + `mysqltest timed out after ${MARIADB_PROCESS_TIMEOUT_MS}ms`, + ); return { ...instance.getProcessOutput(pid), exitCode }; } @@ -268,29 +448,31 @@ export async function runMariaDBBenchmark(engine: string, arch: WasmArch = "wasm // 2. Start server const instance = await startMariaDB(arch, dataDir, false, engineArgs); - - // Wait for server readiness. Avoid a socket-level probe here: the Node TCP - // bridge treats the probe as a real MariaDB client connection, and aborting - // that connection can destabilize the benchmark before mysqltest starts. - const deadline = Date.now() + 120_000; - let ready = false; - while (Date.now() < deadline) { - if (instance.getOutput().includes("ready for connections")) { - ready = true; - break; + try { + // Wait for server readiness. Avoid a socket-level probe here: the Node TCP + // bridge treats the probe as a real MariaDB client connection, and aborting + // that connection can destabilize the benchmark before mysqltest starts. + const deadline = Date.now() + MARIADB_PROCESS_TIMEOUT_MS; + let ready = false; + while (Date.now() < deadline) { + instance.assertLiveServer("while waiting for readiness"); + if (instance.getOutput().includes("ready for connections")) { + ready = true; + break; + } + await instance.raceLiveServer( + new Promise((resolveSleep) => setTimeout(resolveSleep, 500)), + "while waiting for readiness", + ); + } + if (!ready) { + const output = instance.getOutput().trim(); + throw new Error( + `MariaDB ${arch} server did not listen on port ${instance.port}` + + (output ? `:\n${output}` : ""), + ); } - await new Promise((r) => setTimeout(r, 500)); - } - if (!ready) { - const output = instance.getOutput().trim(); - await instance.cleanup(); - throw new Error( - `MariaDB ${arch} server did not listen on port ${instance.port}` + - (output ? `:\n${output}` : ""), - ); - } - try { // 3. CREATE TABLE const t1 = performance.now(); await runMysqlTestChecked(arch, instance,` diff --git a/benchmarks/suites/wordpress-state.ts b/benchmarks/suites/wordpress-state.ts new file mode 100644 index 0000000000..245db24438 --- /dev/null +++ b/benchmarks/suites/wordpress-state.ts @@ -0,0 +1,48 @@ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { dirname, join } from "node:path"; + +export interface WordPressMeasurementState { + databaseDirectory: string; + debugLogPath: string; + opcacheCacheDirectory: string; +} + +/** + * Give each suite round its own benchmark-owned OPcache root. Measurement + * subdirectories are reset independently so the CLI and HTTP metrics cannot + * reuse compiled scripts from each other or from another benchmark process. + */ +export function createWordPressOpcacheRunDirectory(resultsDirectory: string): string { + mkdirSync(resultsDirectory, { recursive: true }); + return mkdtempSync(join(resultsDirectory, ".wordpress-opcache-run-")); +} + +/** Restore the WordPress setup state and an empty file cache. */ +export function resetWordPressMeasurementState(state: WordPressMeasurementState): void { + rmSync(state.databaseDirectory, { recursive: true, force: true }); + mkdirSync(state.databaseDirectory, { recursive: true }); + rmSync(state.debugLogPath, { force: true }); + + rmSync(state.opcacheCacheDirectory, { recursive: true, force: true }); + mkdirSync(state.opcacheCacheDirectory, { recursive: true }); +} + +export function removeWordPressOpcacheRunDirectory(runDirectory: string): void { + rmSync(runDirectory, { recursive: true, force: true }); +} + +export function buildPhpOpcacheArgs( + opcacheExtensionPath: string, + fileCachePath: string, +): string[] { + return [ + "-d", `extension_dir=${dirname(opcacheExtensionPath)}`, + "-d", "zend_extension=opcache", + "-d", "opcache.enable=1", + "-d", "opcache.enable_cli=1", + "-d", `opcache.file_cache=${fileCachePath}`, + "-d", "opcache.file_cache_only=1", + "-d", "opcache.memory_consumption=128", + "-d", "opcache.validate_timestamps=0", + ]; +} diff --git a/benchmarks/suites/wordpress.ts b/benchmarks/suites/wordpress.ts index 7ac3ab1a38..ba47b47047 100644 --- a/benchmarks/suites/wordpress.ts +++ b/benchmarks/suites/wordpress.ts @@ -1,7 +1,7 @@ /** * Suite 2: WordPress * - * Two measurements: + * Two clean-start measurements: * cli_require_ms — php -r "require 'wp-load.php';" (process start to exit) * http_first_response_ms — Start PHP built-in server, time to first HTTP response */ @@ -13,6 +13,12 @@ import { NodeKernelHost } from "../../host/src/node-kernel-host.js"; import { tryResolveBinary } from "../../host/src/binary-resolver.js"; import { NodePlatformIO } from "../../host/src/platform/node.js"; import type { BenchmarkSuite } from "../types.js"; +import { + buildPhpOpcacheArgs, + createWordPressOpcacheRunDirectory, + removeWordPressOpcacheRunDirectory, + resetWordPressMeasurementState, +} from "./wordpress-state.js"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(__dirname, "../.."); @@ -23,6 +29,44 @@ const phpBinaryPath = const opcachePath = tryResolveBinary("programs/php/opcache.so"); const wpDir = resolve(repoRoot, "packages/registry/wordpress/wordpress"); const routerScript = resolve(repoRoot, "packages/registry/wordpress/demo/router.php"); +const benchmarkResultsDir = resolve(repoRoot, "benchmarks/results"); +const databaseDirectory = join(wpDir, "wp-content/database"); +const debugLogPath = join(wpDir, "wp-content/debug.log"); + +function resetMeasurementState(opcacheCacheDirectory: string): void { + resetWordPressMeasurementState({ + databaseDirectory, + debugLogPath, + opcacheCacheDirectory, + }); +} + +/** Filesystem inputs selected by the Node WordPress benchmark. */ +export function describeWordPressBenchmarkInputs(): { + phpBinaryPath: string; + phpResolverRequest: string; + phpResolverSelectedPath: string | null; + opcachePath: string | null; + opcacheResolverRequest: string; + opcacheUsed: boolean; + wpDir: string; + wpConfigPath: string; + routerScript: string; +} { + const phpResolverRequest = "programs/php/php.wasm"; + const opcacheResolverRequest = "programs/php/opcache.so"; + return { + phpBinaryPath, + phpResolverRequest, + phpResolverSelectedPath: tryResolveBinary(phpResolverRequest), + opcachePath, + opcacheResolverRequest, + opcacheUsed: process.env.NO_OPCACHE !== "1" && opcachePath !== null, + wpDir, + wpConfigPath: join(wpDir, "wp-config.php"), + routerScript, + }; +} function loadBytes(path: string): ArrayBuffer { const buf = readFileSync(path); @@ -39,41 +83,44 @@ function missingPrereqsMessage(): string | null { return null; } -async function measureCliRequire(): Promise { - const opcacheArgs = phpOpcacheArgs(); - const t0 = performance.now(); - const result = await runCentralizedProgram({ - programPath: phpBinaryPath, - argv: ["php", ...opcacheArgs, "-r", `chdir('${wpDir}'); require 'wp-load.php';`], - env: ["HOME=/tmp", "TMPDIR=/tmp"], - io: new NodePlatformIO(), - timeout: 120_000, - }); - const t1 = performance.now(); - if (result.exitCode !== 0) { - throw new Error(`PHP wp-load.php failed (exit ${result.exitCode}): ${result.stderr}`); +async function measureCliRequire(opcacheCacheDirectory: string): Promise { + resetMeasurementState(opcacheCacheDirectory); + try { + const opcacheArgs = phpOpcacheArgs(opcacheCacheDirectory); + const t0 = performance.now(); + const result = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", ...opcacheArgs, "-r", `chdir('${wpDir}'); require 'wp-load.php';`], + env: ["HOME=/tmp", "TMPDIR=/tmp"], + io: new NodePlatformIO(), + timeout: 120_000, + }); + const t1 = performance.now(); + if (result.exitCode !== 0) { + throw new Error(`PHP wp-load.php failed (exit ${result.exitCode}): ${result.stderr}`); + } + return t1 - t0; + } finally { + resetMeasurementState(opcacheCacheDirectory); } - return t1 - t0; } -function phpOpcacheArgs(): string[] { +function phpOpcacheArgs(fileCachePath: string): string[] { if (process.env.NO_OPCACHE === "1" || !opcachePath) return []; - return [ - "-d", `extension_dir=${dirname(opcachePath)}`, - "-d", "zend_extension=opcache", - "-d", "opcache.enable=1", - "-d", "opcache.enable_cli=1", - "-d", "opcache.memory_consumption=128", - "-d", "opcache.validate_timestamps=0", - ]; + return buildPhpOpcacheArgs(opcachePath, fileCachePath); } -async function measureHttpFirstResponse(): Promise { +async function measureHttpFirstResponse(opcacheCacheDirectory: string): Promise { + resetMeasurementState(opcacheCacheDirectory); const port = 19400 + Math.floor(Math.random() * 100); const programBytes = loadBytes(phpBinaryPath); let serverStarted = false; let stderr = ""; + let serverOutcome: + | { exitCode: number } + | { error: unknown } + | undefined; const host = new NodeKernelHost({ maxWorkers: 4, @@ -84,56 +131,83 @@ async function measureHttpFirstResponse(): Promise { }, }); - await host.init(); - - const t0 = performance.now(); - const opcacheArgs = phpOpcacheArgs(); - - const exitPromise = host.spawn(programBytes, [ - "php", ...opcacheArgs, "-S", `0.0.0.0:${port}`, "-t", wpDir, routerScript, - ], { - env: ["HOME=/tmp", "TMPDIR=/tmp"], - cwd: wpDir, - }); - void exitPromise; + const throwIfServerExited = (): void => { + if (!serverOutcome) return; + if ("error" in serverOutcome) { + throw new Error( + `PHP server failed before the WordPress response: ${String(serverOutcome.error)}\n${stderr}`, + ); + } + throw new Error( + `PHP server exited with status ${serverOutcome.exitCode} before the WordPress response:\n${stderr}`, + ); + }; + + try { + await host.init(); + + const t0 = performance.now(); + const opcacheArgs = phpOpcacheArgs(opcacheCacheDirectory); + + const exitPromise = host.spawn(programBytes, [ + "php", ...opcacheArgs, "-S", `0.0.0.0:${port}`, "-t", wpDir, routerScript, + ], { + env: ["HOME=/tmp", "TMPDIR=/tmp"], + cwd: wpDir, + }); + void exitPromise.then( + (exitCode) => { serverOutcome = { exitCode }; }, + (error) => { serverOutcome = { error }; }, + ); + + // Wait for server startup (PHP prints "Development Server started" to stderr) + const startDeadline = Date.now() + 60_000; + while (!serverStarted && Date.now() < startDeadline) { + throwIfServerExited(); + await new Promise((r) => setTimeout(r, 200)); + } + throwIfServerExited(); + if (!serverStarted) { + throw new Error(`PHP server did not start within 60s:\n${stderr}`); + } - // Wait for server startup (PHP prints "Development Server started" to stderr) - const startDeadline = Date.now() + 60_000; - while (!serverStarted && Date.now() < startDeadline) { - await new Promise((r) => setTimeout(r, 200)); - } - if (!serverStarted) { - await host.destroy().catch(() => {}); - throw new Error("PHP server did not start within 60s"); - } + // Poll for the first complete, successful WordPress response. + let lastFetchError: unknown; + const deadline = Date.now() + 180_000; + while (Date.now() < deadline) { + throwIfServerExited(); + + let resp: Response; + try { + resp = await fetch(`http://localhost:${port}/`, { + signal: AbortSignal.timeout(120_000), + }); + } catch (error) { + lastFetchError = error; + throwIfServerExited(); + await new Promise((r) => setTimeout(r, 1000)); + continue; + } - // Poll for first complete HTTP response (WordPress first page load is slow) - let firstResponseMs = -1; - const deadline = Date.now() + 180_000; - while (Date.now() < deadline) { - try { - const resp = await fetch(`http://localhost:${port}/`, { - signal: AbortSignal.timeout(120_000), - }); - if (resp.status > 0) { - await resp.text(); - firstResponseMs = performance.now() - t0; - break; + const body = await resp.text(); + if (!resp.ok) { + throw new Error( + `WordPress returned HTTP ${resp.status}: ${body.replace(/\s+/g, " ").slice(0, 240)}`, + ); + } + if (!/wordpress/i.test(body)) { + throw new Error("WordPress benchmark received a successful response without WordPress content"); } - } catch { - // Not ready yet — retry + return performance.now() - t0; } - await new Promise((r) => setTimeout(r, 1000)); - } - - // Cleanup - await host.destroy().catch(() => {}); - if (firstResponseMs < 0) { - throw new Error("Timed out waiting for WordPress HTTP response"); + throw new Error( + `Timed out waiting for WordPress HTTP response: ${String(lastFetchError ?? "no response")}`, + ); + } finally { + await host.destroy().catch(() => {}); + resetMeasurementState(opcacheCacheDirectory); } - - return firstResponseMs; } const suite: BenchmarkSuite = { @@ -145,10 +219,17 @@ const suite: BenchmarkSuite = { throw new Error(`WordPress benchmark prerequisites are missing. ${missing}`); } - const results: Record = {}; - results.cli_require_ms = await measureCliRequire(); - results.http_first_response_ms = await measureHttpFirstResponse(); - return results; + const opcacheRunDirectory = createWordPressOpcacheRunDirectory(benchmarkResultsDir); + try { + const results: Record = {}; + results.cli_require_ms = await measureCliRequire(join(opcacheRunDirectory, "cli")); + results.http_first_response_ms = await measureHttpFirstResponse( + join(opcacheRunDirectory, "http"), + ); + return results; + } finally { + removeWordPressOpcacheRunDirectory(opcacheRunDirectory); + } }, }; diff --git a/benchmarks/timeout.test.ts b/benchmarks/timeout.test.ts new file mode 100644 index 0000000000..448f534c32 --- /dev/null +++ b/benchmarks/timeout.test.ts @@ -0,0 +1,58 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + withRejectingTimeout, + type TimeoutScheduler, +} from "./timeout.js"; + +function controlledScheduler(): { + scheduler: TimeoutScheduler; + fire: () => void; + clearCount: () => number; +} { + let callback: (() => void) | undefined; + let clears = 0; + const handle = Symbol("timeout") as unknown as ReturnType; + return { + scheduler: { + setTimeout(nextCallback) { + callback = nextCallback; + return handle; + }, + clearTimeout(receivedHandle) { + assert.equal(receivedHandle, handle); + clears++; + }, + }, + fire: () => callback?.(), + clearCount: () => clears, + }; +} + +test("clears the timeout when the operation completes", async () => { + const controlled = controlledScheduler(); + const result = await withRejectingTimeout( + Promise.resolve("done"), + 100, + "too slow", + controlled.scheduler, + ); + + assert.equal(result, "done"); + assert.equal(controlled.clearCount(), 1); +}); + +test("rejects at the deadline and clears the timeout", async () => { + const controlled = controlledScheduler(); + const pending = new Promise(() => {}); + const result = withRejectingTimeout( + pending, + 100, + "benchmark round timed out", + controlled.scheduler, + ); + controlled.fire(); + + await assert.rejects(result, /benchmark round timed out/); + assert.equal(controlled.clearCount(), 1); +}); diff --git a/benchmarks/timeout.ts b/benchmarks/timeout.ts new file mode 100644 index 0000000000..25643d6e7f --- /dev/null +++ b/benchmarks/timeout.ts @@ -0,0 +1,28 @@ +export interface TimeoutScheduler { + setTimeout(callback: () => void, delayMs: number): ReturnType; + clearTimeout(handle: ReturnType): void; +} + +const systemTimeoutScheduler: TimeoutScheduler = { + setTimeout: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimeout: (handle) => clearTimeout(handle), +}; + +/** Bound an operation with a rejecting timer and always retire that timer. */ +export async function withRejectingTimeout( + operation: Promise, + timeoutMs: number, + message: string, + scheduler: TimeoutScheduler = systemTimeoutScheduler, +): Promise { + let timeoutHandle: ReturnType | undefined; + const timeout = new Promise((_, reject) => { + timeoutHandle = scheduler.setTimeout(() => reject(new Error(message)), timeoutMs); + }); + + try { + return await Promise.race([operation, timeout]); + } finally { + if (timeoutHandle !== undefined) scheduler.clearTimeout(timeoutHandle); + } +} diff --git a/benchmarks/types.ts b/benchmarks/types.ts index f369ed85d1..cd1cde4d4d 100644 --- a/benchmarks/types.ts +++ b/benchmarks/types.ts @@ -8,10 +8,36 @@ export interface BenchmarkSuite { } export interface BenchmarkArtifactFile { + /** Stable logical name for the input. */ path: string; + /** Exact filesystem path selected for this run, when one was found. */ + selectedPath?: string; + /** Binary-resolver request used to locate the input. */ + resolverRequest?: string; + /** Exact path returned by the binary resolver before any browser copy. */ + resolverSelectedPath?: string; sizeBytes?: number; sha256?: string; missing?: boolean; + required?: boolean; + used?: boolean; + error?: string; +} + +export interface BenchmarkArtifactDirectory { + /** Stable logical name for the input tree. */ + path: string; + /** Exact filesystem path traversed for this run. */ + selectedPath?: string; + fileCount?: number; + sizeBytes?: number; + sha256?: string; + missing?: boolean; + required?: boolean; + used?: boolean; + /** Runtime-owned paths intentionally omitted from the digest. */ + excludedPaths?: string[]; + error?: string; } export interface ForkBenchSymbolReport { @@ -26,6 +52,7 @@ export interface BenchmarkArtifacts { gitHead: string; gitRef: string; files: Record; + directories?: Record; forkBench: ForkBenchSymbolReport; } diff --git a/crates/fork-instrument/Cargo.toml b/crates/fork-instrument/Cargo.toml index 3fdc441aae..bafaaefb75 100644 --- a/crates/fork-instrument/Cargo.toml +++ b/crates/fork-instrument/Cargo.toml @@ -25,11 +25,13 @@ anyhow = "1" # clap — argument parsing. `derive` feature for #[derive(Parser)]. clap = { version = "4", features = ["derive"] } -[dev-dependencies] # wasmparser — independent validator used in tests to confirm walrus -# output round-trips cleanly through a separate implementation. +# output round-trips cleanly through a separate implementation. The +# instrumenter also uses its section ranges to keep a leading dylink.0 section +# first when Walrus re-emits a shared module. wasmparser = "0.247" +[dev-dependencies] # wasmprinter — byte-level wasm-to-WAT for assertions that walrus's IR # can't see (notably post-Br "unreachable" code that walrus's parser # strips on round-trip but is preserved in the actual wasm bytes). diff --git a/crates/fork-instrument/src/call_graph.rs b/crates/fork-instrument/src/call_graph.rs index 3585dd0018..cc58e4971e 100644 --- a/crates/fork-instrument/src/call_graph.rs +++ b/crates/fork-instrument/src/call_graph.rs @@ -569,6 +569,23 @@ fn types_match(module: &Module, a: TypeId, b: TypeId) -> bool { const MAX_INDIRECT_DEPTH: u8 = 2; +/// Whether this module can resolve and invoke functions installed by Kandelo's +/// dynamic linker after static call-graph analysis has completed. +/// +/// This predicate is also used when emitting the versioned fork capability +/// marker. Keep it as the single source of truth for both the conservative +/// closure below and the artifact claim consumed by the host runtime. +pub fn has_dynamic_linker_imports(module: &Module) -> bool { + module.imports.iter().any(|import| { + import.module == "env" + && matches!(import.kind, ImportKind::Function(_)) + && matches!( + import.name.as_str(), + "__wasm_dlopen" | "__wasm_dlsym" | "__wasm_dlclose" | "__wasm_dlerror" + ) + }) +} + /// Compute the transitive closure of functions that reach `seed` via /// direct calls, plus a bounded number of table/function-pointer dispatches. /// @@ -589,6 +606,12 @@ const MAX_INDIRECT_DEPTH: u8 = 2; pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet { let profiles = profile_functions(module); let table_targets = table_targets(module, &profiles); + // A dlsym result can be installed into the main module's table only after + // static analysis. Every call_indirect in a dlopen-capable main module is + // therefore a possible boundary above a fork-capable side-module frame. + // Keep this opt-in to the dynamic-linker imports so ordinary programs + // retain the precise table-target closure below. + let has_dynamic_linker_imports = has_dynamic_linker_imports(module); // Reverse direct-call graph: `callee -> set of callers`. let mut reverse_direct: HashMap> = HashMap::new(); @@ -652,6 +675,20 @@ pub fn reaching_closure(module: &Module, seed: FunctionId) -> HashSet = regions.keys().copied().collect(); + region_ids.sort(); + // Compute, for each fork-bearing seq, the call_idxs of its DIRECT // fork-path calls (ordered by DFS, == order in `sites`). Used by // argument materialization, the carryover pre-pass, and the @@ -6080,7 +6087,7 @@ fn instrument_one_function_nested_switch( // Snapshot needed seq+landing data first (immutable borrow), // then allocate locals (mutable borrow). let mut pending_plans: Vec<(InstrSeqId, usize, PendingCarryoverPlan)> = Vec::new(); - for &seq_id in regions.keys() { + for &seq_id in ®ion_ids { let direct = direct_idxs_per_seq.get(&seq_id).unwrap_or(&empty_idxs); // Sub-commit 2.6a: typed analyser captures per-landing // spill ValTypes (covering both SubRegion type-params and @@ -6187,7 +6194,7 @@ fn instrument_one_function_nested_switch( _ => unreachable!(), }; let mut to_allocate: Vec<(InstrSeqId, Vec)> = Vec::new(); - for &seq_id in regions.keys() { + for &seq_id in ®ion_ids { if let InstrSeqType::MultiValue(ty_id) = local_ro.block(seq_id).ty { let params = module.types.get(ty_id).params(); if !params.is_empty() { @@ -6286,12 +6293,20 @@ fn instrument_one_function_nested_switch( // Process all fork-bearing seqs except the entry (entry handled // specially with preamble/postamble + unwind_save). Order: // bottom-up (deepest first). - let mut non_entry_regions: Vec = - regions.keys().copied().filter(|&s| s != entry_id).collect(); + let mut non_entry_regions: Vec = region_ids + .iter() + .copied() + .filter(|&s| s != entry_id) + .collect(); // Sort by depth (deepest first). Walrus doesn't expose depth // directly, so compute via parent-seq walk. let depth_map = compute_seq_depths(local, entry_id); - non_entry_regions.sort_by_key(|s| std::cmp::Reverse(depth_map.get(s).copied().unwrap_or(0))); + non_entry_regions.sort_by_key(|s| { + ( + std::cmp::Reverse(depth_map.get(s).copied().unwrap_or(0)), + *s, + ) + }); // Transform non-entry regions bottom-up. for seq_id in non_entry_regions { diff --git a/crates/fork-instrument/src/lib.rs b/crates/fork-instrument/src/lib.rs index da9b008127..0560e0cf87 100644 --- a/crates/fork-instrument/src/lib.rs +++ b/crates/fork-instrument/src/lib.rs @@ -14,12 +14,21 @@ //! - Phase 6: catch-handler region support //! - Phase 7: production rollout -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, bail, ensure}; +use walrus::RawCustomSection; +use wasmparser::{Parser, Payload}; pub mod call_graph; pub mod instrument; pub mod runtime; +/// Versioned artifact claim emitted by `wasm-fork-instrument` and consumed by +/// the host before it enables cross-module fork coordination. +pub const FORK_CAPABILITIES_SECTION: &str = "kandelo.wpk_fork.capabilities"; +pub const FORK_CAPABILITIES_VERSION: u8 = 1; +pub const FORK_CAP_SIDE_ENTRY: u8 = 1 << 0; +pub const FORK_CAP_DYLINK_MAIN: u8 = 1 << 1; + /// Options controlling instrumentation. Fields will grow as phases /// land; a `Default` implementation keeps call sites stable. #[derive(Debug, Clone)] @@ -91,11 +100,29 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { // the runtime's own injected functions are not mistaken for // fork-path callers. (They can't reach the seed anyway, but the // earlier-is-simpler ordering keeps the invariant trivially.) - let fork_path = match call_graph::find_import_func(&module, &opts.entry_import) { + let entry = call_graph::find_import_func(&module, &opts.entry_import); + let fork_path = match entry { Some(seed) => call_graph::reaching_closure(&module, seed), None => Default::default(), }; + // The five wpk_fork_* exports prove only that some instrumentation runtime + // was injected. They do not prove which import seeded the transformed call + // graph or whether a dlopen-capable main used the conservative dynamic + // call_indirect boundary. Emit a separate, versioned claim for exactly the + // transformations performed in this invocation so the host can reject + // stale or generically instrumented artifacts instead of mis-resuming. + let mut fork_capabilities = 0; + if entry.is_some() && opts.entry_import == "env.fork" { + fork_capabilities |= FORK_CAP_SIDE_ENTRY; + } + if entry.is_some() + && opts.entry_import == "kernel.kernel_fork" + && call_graph::has_dynamic_linker_imports(&module) + { + fork_capabilities |= FORK_CAP_DYLINK_MAIN; + } + // Phase 4a: runtime scaffolding. Always injected so the module's // exported ABI is stable regardless of whether any caller was // actually rewritten. @@ -127,6 +154,20 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { // No-op when `fork_path` is empty (module doesn't use fork). instrument::instrument_functions(&mut module, &runtime, &fork_path, &b1_plan); + loop { + let existing = module + .customs + .iter() + .find(|(_, section)| section.name() == FORK_CAPABILITIES_SECTION) + .map(|(id, _)| id); + let Some(existing) = existing else { break }; + module.customs.delete(existing); + } + module.customs.add(RawCustomSection { + name: FORK_CAPABILITIES_SECTION.into(), + data: vec![FORK_CAPABILITIES_VERSION, fork_capabilities], + }); + // Historical phase list (Phase 4b/4c/4d/4e/4f/5/6) was an artefact // of guard-dispatch's body-rewriting approach. Post-commit-4 those // phases are folded into `instrument::instrument_functions` itself; @@ -134,5 +175,73 @@ pub fn instrument(input: &[u8], opts: &Options) -> Result> { // for the actual transform. let output = module.emit_wasm(); - Ok(output) + restore_leading_dylink_section(input, output) +} + +/// Walrus emits raw custom sections after the standard sections. That is +/// normally valid, but the WebAssembly dynamic-linking convention requires a +/// shared module's `dylink.0` custom section to be first. Preserve that input +/// contract after instrumentation so Kandelo's dynamic loader can still +/// recognize fork-capable side modules. +fn restore_leading_dylink_section(input: &[u8], output: Vec) -> Result> { + let mut input_payloads = Parser::new(0).parse_all(input); + let _version = input_payloads + .next() + .transpose() + .context("parsing input wasm header")?; + let input_starts_with_dylink = matches!( + input_payloads.next().transpose()?, + Some(Payload::CustomSection(section)) if section.name() == "dylink.0" + ); + if !input_starts_with_dylink { + return Ok(output); + } + + let mut dylink_range = None; + for payload in Parser::new(0).parse_all(&output) { + if let Payload::CustomSection(section) = payload? { + if section.name() != "dylink.0" { + continue; + } + ensure!( + dylink_range.is_none(), + "instrumented module contains more than one dylink.0 section" + ); + dylink_range = Some(section.range()); + } + } + + let payload_range = dylink_range.context( + "input shared module started with dylink.0, but the instrumented output lost it", + )?; + let payload_len = u32::try_from(payload_range.len()) + .context("dylink.0 section is too large for a wasm section")?; + let section_header_len = 1 + u32_leb_len(payload_len); + let section_start = payload_range + .start + .checked_sub(section_header_len) + .context("dylink.0 section range does not include a valid header")?; + ensure!( + section_start >= 8 && output.get(section_start) == Some(&0), + "dylink.0 section does not have a valid custom-section header" + ); + if section_start == 8 { + return Ok(output); + } + + let mut reordered = Vec::with_capacity(output.len()); + reordered.extend_from_slice(&output[..8]); + reordered.extend_from_slice(&output[section_start..payload_range.end]); + reordered.extend_from_slice(&output[8..section_start]); + reordered.extend_from_slice(&output[payload_range.end..]); + Ok(reordered) +} + +fn u32_leb_len(mut value: u32) -> usize { + let mut len = 1; + while value >= 0x80 { + value >>= 7; + len += 1; + } + len } diff --git a/crates/fork-instrument/src/main.rs b/crates/fork-instrument/src/main.rs index dde9b50a96..8f63f54983 100644 --- a/crates/fork-instrument/src/main.rs +++ b/crates/fork-instrument/src/main.rs @@ -13,7 +13,9 @@ use anyhow::{Context, Result}; use clap::Parser; use std::fs; -use std::path::PathBuf; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; use fork_instrument::{Options, analyze, instrument}; @@ -65,16 +67,48 @@ fn main() -> Result<()> { let output_path = cli.output.as_ref().ok_or_else(|| { anyhow::anyhow!("--output is required unless --discover-only is set") })?; + // Capture this before writing: `--output` is allowed to name the input + // file, and output creation/truncation must not become the source of truth + // for the executable mode we are preserving. + let input_mode = input_mode(&cli.input)?; let output = instrument(&input, &opts) .with_context(|| format!("instrumenting {}", cli.input.display()))?; fs::write(output_path, &output) .with_context(|| format!("writing output: {}", output_path.display()))?; + preserve_input_mode(input_mode, output_path)?; Ok(()) } +#[cfg(unix)] +fn input_mode(input_path: &Path) -> Result { + let mode = fs::metadata(input_path) + .with_context(|| format!("stat input for permissions: {}", input_path.display()))? + .permissions() + .mode(); + Ok(mode) +} + +#[cfg(not(unix))] +fn input_mode(_input_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn preserve_input_mode(input_mode: u32, output_path: &Path) -> Result<()> { + let permissions = fs::Permissions::from_mode(input_mode); + fs::set_permissions(output_path, permissions) + .with_context(|| format!("setting output permissions: {}", output_path.display()))?; + Ok(()) +} + +#[cfg(not(unix))] +fn preserve_input_mode(_input_mode: (), _output_path: &Path) -> Result<()> { + Ok(()) +} + fn print_analysis_json(analysis: &fork_instrument::Analysis) { // Hand-rolled JSON to avoid a serde dependency for a tiny output. // Format is one-entry-per-line array of `{name, is_import}` objects. diff --git a/crates/fork-instrument/tests/call_graph.rs b/crates/fork-instrument/tests/call_graph.rs index 425151f333..fefcb897b1 100644 --- a/crates/fork-instrument/tests/call_graph.rs +++ b/crates/fork-instrument/tests/call_graph.rs @@ -388,6 +388,35 @@ fn passive_element_with_table_init_is_followed() { ); } +#[test] +fn dynamic_linker_indirect_call_is_conservative_fork_boundary() { + // Side-module functions inserted after instrumentation are absent from + // every static element segment. A dlopen-capable main must still preserve + // the call_indirect frame and its direct callers when that side function + // later reaches fork(). + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlsym" (func $dlsym (param i32 i32 i32) (result i32))) + (type $side_fn_ty (func (result i32))) + (table $t 1 funcref) + (func $dispatch_side_callback (export "dispatch_side_callback") (result i32) + i32.const 0 + call_indirect $t (type $side_fn_ty)) + (func $parent_frame (export "parent_frame") (result i32) + call $dispatch_side_callback) + (func $ordinary (export "ordinary") (result i32) + i32.const 7)) + "#; + let found = discover(wat); + assert!(found.iter().any(|n| n == "dispatch_side_callback")); + assert!(found.iter().any(|n| n == "parent_frame")); + assert!( + !found.iter().any(|n| n == "ordinary"), + "unrelated functions must stay out of the dynamic fork closure: {found:?}" + ); +} + #[test] fn constant_slot_pointing_to_safe_target_excludes_indirect_caller() { // Both functions have the same signature and inhabit the same table. diff --git a/crates/fork-instrument/tests/cli_permissions.rs b/crates/fork-instrument/tests/cli_permissions.rs new file mode 100644 index 0000000000..5097e5d6c5 --- /dev/null +++ b/crates/fork-instrument/tests/cli_permissions.rs @@ -0,0 +1,95 @@ +#![cfg(unix)] + +use std::fs; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_TEMP_ID: AtomicU64 = AtomicU64::new(0); + +const INPUT_WAT: &str = r#" +(module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (memory 1) + (func (export "_start") + (drop (call $fork)))) +"#; + +struct TempDir(PathBuf); + +impl TempDir { + fn new(test_name: &str) -> Self { + let id = NEXT_TEMP_ID.fetch_add(1, Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "kandelo-fork-instrument-{test_name}-{}-{id}", + std::process::id() + )); + fs::create_dir(&path).expect("create test directory"); + Self(path) + } + + fn join(&self, path: impl AsRef) -> PathBuf { + self.0.join(path) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } +} + +fn write_input(path: &Path, mode: u32) { + fs::write(path, wat::parse_str(INPUT_WAT).expect("compile input WAT")) + .expect("write input Wasm"); + fs::set_permissions(path, fs::Permissions::from_mode(mode)).expect("set input mode"); +} + +fn run_instrumenter(input: &Path, output: &Path) { + let status = Command::new(env!("CARGO_BIN_EXE_wasm-fork-instrument")) + .arg(input) + .arg("--output") + .arg(output) + .status() + .expect("run wasm-fork-instrument"); + assert!(status.success(), "instrumenter exited with {status}"); +} + +fn mode(path: &Path) -> u32 { + fs::metadata(path) + .expect("stat output") + .permissions() + .mode() + & 0o7777 +} + +#[test] +fn new_output_preserves_input_mode() { + let dir = TempDir::new("new-output"); + let input = dir.join("input.wasm"); + let output = dir.join("output.wasm"); + write_input(&input, 0o751); + + run_instrumenter(&input, &output); + + assert_eq!(mode(&input), 0o751); + assert_eq!(mode(&output), 0o751); + wasmparser::Validator::new() + .validate_all(&fs::read(output).expect("read output")) + .expect("instrumented output validates"); +} + +#[test] +fn in_place_output_preserves_input_mode() { + let dir = TempDir::new("in-place"); + let input = dir.join("program.wasm"); + write_input(&input, 0o711); + + run_instrumenter(&input, &input); + + assert_eq!(mode(&input), 0o711); + wasmparser::Validator::new() + .validate_all(&fs::read(input).expect("read output")) + .expect("instrumented output validates"); +} diff --git a/crates/fork-instrument/tests/instrument.rs b/crates/fork-instrument/tests/instrument.rs index b901b58ed9..d69a04bafd 100644 --- a/crates/fork-instrument/tests/instrument.rs +++ b/crates/fork-instrument/tests/instrument.rs @@ -2412,6 +2412,82 @@ fn b1_stage_2_byte_identity_for_module_without_plain_catch() { ); } +#[test] +fn nested_region_instrumentation_is_byte_reproducible() { + // Sibling fork-bearing regions used to be visited through randomized + // HashMap iteration. Their body-parameter locals and rewritten instruction + // sequences consequently received different Walrus IDs across runs, even + // though the input was identical. Alternate parameter types so a changed + // allocation order is observable in the emitted local declarations. + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (type $i32_to_i32 (func (param i32) (result i32))) + (type $i64_to_i64 (func (param i64) (result i64))) + (func $caller (export "caller") + i32.const 1 + (block (type $i32_to_i32) + drop + call $fork) + drop + i64.const 2 + (block (type $i64_to_i64) + drop + call $fork + i64.extend_i32_s) + drop + i32.const 3 + (block (type $i32_to_i32) + drop + call $fork) + drop + i64.const 4 + (block (type $i64_to_i64) + drop + call $fork + i64.extend_i32_s) + drop) + (memory 1)) + "#; + + let expected = instrument_wat(wat); + validate(&expected); + for run in 1..=8 { + assert_eq!( + expected, + instrument_wat(wat), + "nested instrumentation changed bytes on run {run}" + ); + } +} + +#[test] +fn fork_instrumentation_keeps_dylink_section_first() { + let wat = r#" + (module + (@custom "dylink.0" (before first) "test metadata") + (import "kernel" "kernel_fork" (func $fork (result i32))) + (func $caller (export "caller") (result i32) + call $fork) + (memory 1)) + "#; + + let output = instrument_wat(wat); + validate(&output); + let mut payloads = wasmparser::Parser::new(0).parse_all(&output); + assert!(matches!( + payloads.next().unwrap().unwrap(), + wasmparser::Payload::Version { .. } + )); + match payloads.next().unwrap().unwrap() { + wasmparser::Payload::CustomSection(section) => { + assert_eq!(section.name(), "dylink.0"); + assert_eq!(section.data(), b"test metadata"); + } + other => panic!("dylink.0 must remain the first section, got {other:?}"), + } +} + // ====================================================================== // Stage 2 (B1) Task 2.3 — multi-arm rewind dispatch // ====================================================================== diff --git a/crates/fork-instrument/tests/runtime.rs b/crates/fork-instrument/tests/runtime.rs index f548015ca8..d2e76dd212 100644 --- a/crates/fork-instrument/tests/runtime.rs +++ b/crates/fork-instrument/tests/runtime.rs @@ -11,9 +11,13 @@ //! - Independently validating via wasmparser that the emitted module //! is well-formed. -use fork_instrument::{Options, instrument}; use fork_instrument::runtime::names; +use fork_instrument::{ + FORK_CAP_DYLINK_MAIN, FORK_CAP_SIDE_ENTRY, FORK_CAPABILITIES_SECTION, + FORK_CAPABILITIES_VERSION, Options, instrument, +}; use walrus::{ExportItem, Module, ValType}; +use wasmparser::{Parser, Payload}; fn instrument_wat(wat_src: &str) -> Vec { let bytes = wat::parse_str(wat_src).expect("wat parse"); @@ -27,6 +31,18 @@ fn validate(bytes: &[u8]) { validator.validate_all(bytes).expect("valid wasm"); } +fn fork_capabilities(bytes: &[u8]) -> Vec> { + Parser::new(0) + .parse_all(bytes) + .filter_map(|payload| match payload.expect("parse payload") { + Payload::CustomSection(section) if section.name() == FORK_CAPABILITIES_SECTION => { + Some(section.data().to_vec()) + } + _ => None, + }) + .collect() +} + fn export_function_id(module: &Module, name: &str) -> walrus::FunctionId { let export = module .exports @@ -57,6 +73,59 @@ fn instrumented_module_validates() { validate(&bytes); } +#[test] +fn marks_dlopen_main_indirect_boundary_separately() { + let wat = r#" + (module + (import "kernel" "kernel_fork" (func $fork (result i32))) + (import "env" "__wasm_dlsym" (func $dlsym (param i32 i32 i32) (result i32))) + (type $callback (func (result i32))) + (table 1 funcref) + (memory 1) + (func (export "dispatch") (result i32) + i32.const 0 + call_indirect (type $callback))) + "#; + let output = instrument_wat(wat); + assert_eq!( + fork_capabilities(&output), + vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_DYLINK_MAIN]], + ); +} + +#[test] +fn marks_env_fork_side_entry_separately() { + let input = wat::parse_str( + r#" + (module + (import "env" "fork" (func $fork (result i32))) + (memory 1) + (func (export "side_fork") (result i32) call $fork)) + "#, + ) + .expect("wat parse"); + let output = instrument( + &input, + &Options { + entry_import: "env.fork".into(), + }, + ) + .expect("instrument side"); + assert_eq!( + fork_capabilities(&output), + vec![vec![FORK_CAPABILITIES_VERSION, FORK_CAP_SIDE_ENTRY]], + ); +} + +#[test] +fn generic_runtime_exports_do_not_claim_side_or_dylink_coverage() { + let output = instrument_wat(EMPTY_MODULE_WITH_FORK); + assert_eq!( + fork_capabilities(&output), + vec![vec![FORK_CAPABILITIES_VERSION, 0]], + ); +} + #[test] fn injects_state_global_mutable_i32_init_zero() { let bytes = instrument_wat(EMPTY_MODULE_WITH_FORK); diff --git a/crates/kernel/src/audio.rs b/crates/kernel/src/audio.rs index 2b5e5b8af5..86aea81e81 100644 --- a/crates/kernel/src/audio.rs +++ b/crates/kernel/src/audio.rs @@ -22,9 +22,9 @@ //! Like `/dev/fb0` and `/dev/input/mice`, `/dev/dsp` is single-open. A //! second `open` from a different pid is `EBUSY`. Re-opens by the //! current owner are accepted (matches the typical OSS exclusive-grab -//! model). Owner is released when the process closes its last `/dev/dsp` -//! fd, or exits, or `execve`s — at which point the ring is also -//! cleared so a successor open starts from silence. +//! model). A non-CLOEXEC fd retains ownership and queued samples across +//! `execve`; last close or process exit releases ownership and clears the +//! ring so a successor open starts from silence. //! //! ## Backpressure //! @@ -127,8 +127,8 @@ pub fn pending_bytes() -> usize { ring().len() } -/// Drop all buffered samples. Called on process exit / exec by the -/// owner, and by `SNDCTL_DSP_RESET`. +/// Drop all buffered samples. Called when the owner exits or closes its last +/// fd, and by `SNDCTL_DSP_RESET`. pub fn reset() { ring().clear(); } diff --git a/crates/kernel/src/descriptor_backing.rs b/crates/kernel/src/descriptor_backing.rs new file mode 100644 index 0000000000..81b1843828 --- /dev/null +++ b/crates/kernel/src/descriptor_backing.rs @@ -0,0 +1,467 @@ +//! Kernel-global backings for descriptor types whose state belongs to an +//! open file description rather than to a process. +//! +//! Fork and spawn clone a process's FD/OFD tables. The cloned OFDs retain a +//! stable negative handle into these tables, and each inherited OFD owns one +//! reference. This keeps state coherent across processes and prevents a +//! newly-created descriptor in a child from reusing (and aliasing) an +//! inherited process-local slot. + +extern crate alloc; + +use alloc::vec::Vec; +use core::cell::UnsafeCell; +use core::hint::spin_loop; +use core::sync::atomic::{AtomicBool, Ordering}; + +use wasm_posix_shared::Errno; + +use crate::ofd::FileType; +use crate::process::{EventFdState, Process, SignalFdState, TimerFdState}; + +#[derive(Debug)] +struct SharedBacking { + refs: u32, + value: T, + #[cfg(test)] + generation: u64, +} + +/// A stable-index table with one reference per owning OFD in each process. +pub struct SharedBackingTable { + entries: Vec>>, + #[cfg(test)] + next_generation: u64, +} + +impl SharedBackingTable { + fn new() -> Self { + Self { + entries: Vec::new(), + #[cfg(test)] + next_generation: 1, + } + } + + pub fn alloc(&mut self, value: T) -> usize { + #[cfg(test)] + let entry = { + let generation = self.next_generation; + self.next_generation = self.next_generation.wrapping_add(1).max(1); + SharedBacking { + refs: 1, + value, + generation, + } + }; + #[cfg(not(test))] + let entry = SharedBacking { refs: 1, value }; + + if let Some((idx, slot)) = self + .entries + .iter_mut() + .enumerate() + .find(|(_, slot)| slot.is_none()) + { + *slot = Some(entry); + return idx; + } + + let idx = self.entries.len(); + self.entries.push(Some(entry)); + idx + } + + pub fn get(&self, idx: usize) -> Option<&T> { + self.entries + .get(idx) + .and_then(Option::as_ref) + .map(|entry| &entry.value) + } + + pub fn get_mut(&mut self, idx: usize) -> Option<&mut T> { + self.entries + .get_mut(idx) + .and_then(Option::as_mut) + .map(|entry| &mut entry.value) + } + + pub fn add_ref(&mut self, idx: usize) -> Result<(), Errno> { + let entry = self + .entries + .get_mut(idx) + .and_then(Option::as_mut) + .ok_or(Errno::EBADF)?; + entry.refs = entry.refs.checked_add(1).ok_or(Errno::EOVERFLOW)?; + Ok(()) + } + + /// Drop one owning OFD reference. Returns true when the backing was freed. + pub fn release(&mut self, idx: usize) -> bool { + let Some(slot) = self.entries.get_mut(idx) else { + return false; + }; + let Some(entry) = slot.as_mut() else { + return false; + }; + if entry.refs > 1 { + entry.refs -= 1; + return false; + } + *slot = None; + true + } + + #[cfg(test)] + pub fn ref_count(&self, idx: usize) -> Option { + self.entries + .get(idx) + .and_then(Option::as_ref) + .map(|entry| entry.refs) + } + + #[cfg(test)] + pub fn generation(&self, idx: usize) -> Option { + self.entries + .get(idx) + .and_then(Option::as_ref) + .map(|entry| entry.generation) + } +} + +/// Shared contents and cursor for a memfd open file description. +#[derive(Debug)] +pub struct MemFdBacking { + pub data: Vec, + pub offset: i64, +} + +impl MemFdBacking { + pub fn new() -> Self { + Self { + data: Vec::new(), + offset: 0, + } + } +} + +/// Immutable procfs snapshot plus the shared open-file-description cursor. +#[derive(Debug)] +pub struct ProcfsBacking { + pub data: Vec, + pub offset: i64, +} + +impl ProcfsBacking { + pub fn new(data: Vec) -> Self { + Self { data, offset: 0 } + } +} + +struct GlobalBackingTable { + locked: AtomicBool, + table: UnsafeCell>>, +} + +struct UnlockOnDrop<'a>(&'a AtomicBool); + +impl Drop for UnlockOnDrop<'_> { + fn drop(&mut self) { + self.0.store(false, Ordering::Release); + } +} + +impl GlobalBackingTable { + const fn new() -> Self { + Self { + locked: AtomicBool::new(false), + table: UnsafeCell::new(None), + } + } + + fn with(&'static self, f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R) -> R { + while self + .locked + .compare_exchange_weak(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + spin_loop(); + } + let _unlock = UnlockOnDrop(&self.locked); + // SAFETY: `locked` serializes every access and the closure's + // higher-ranked input lifetime prevents a table reference escaping. + let slot = unsafe { &mut *self.table.get() }; + f(slot.get_or_insert_with(SharedBackingTable::new)) + } +} + +// Kandelo serializes entry into one kernel instance. These tables follow the +// same UnsafeCell-backed global pattern as pipes, sockets, PTYs, and mqueues. +unsafe impl Sync for GlobalBackingTable {} + +static EVENTFDS: GlobalBackingTable = GlobalBackingTable::new(); +static TIMERFDS: GlobalBackingTable = GlobalBackingTable::new(); +static SIGNALFDS: GlobalBackingTable = GlobalBackingTable::new(); +static MEMFDS: GlobalBackingTable = GlobalBackingTable::new(); +static PROCFS_BUFS: GlobalBackingTable = GlobalBackingTable::new(); + +pub fn with_eventfds( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + EVENTFDS.with(f) +} + +pub fn with_timerfds( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + TIMERFDS.with(f) +} + +pub fn with_signalfds( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + SIGNALFDS.with(f) +} + +pub fn with_memfds(f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R) -> R { + MEMFDS.with(f) +} + +pub fn with_procfs_bufs( + f: impl for<'a> FnOnce(&'a mut SharedBackingTable) -> R, +) -> R { + PROCFS_BUFS.with(f) +} + +fn negative_handle_idx(host_handle: i64) -> Result { + if host_handle >= 0 { + return Err(Errno::EBADF); + } + host_handle + .checked_neg() + .and_then(|value| value.checked_sub(1)) + .and_then(|value| usize::try_from(value).ok()) + .ok_or(Errno::EBADF) +} + +#[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) +)] +pub fn manages_ofd(file_type: FileType, host_handle: i64) -> bool { + matches!( + file_type, + FileType::EventFd | FileType::TimerFd | FileType::SignalFd | FileType::MemFd + ) || (file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle)) +} + +/// Plan ownership transfer for the legacy serialize/init exec ABI. Surviving +/// OFDs take over the old process's existing ownership reference; old OFDs +/// omitted by CLOEXEC filtering must be released exactly once. A replacement +/// may not acquire a backing it did not already own. +#[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) +)] +pub fn removed_backings_for_exec( + old: &Process, + replacement: &Process, +) -> Result, Errno> { + let old_backings: Vec<(FileType, i64)> = old + .ofd_table + .iter() + .filter_map(|(_, ofd)| { + manages_ofd(ofd.file_type, ofd.host_handle).then_some((ofd.file_type, ofd.host_handle)) + }) + .collect(); + let mut retained = alloc::vec![false; old_backings.len()]; + + for (_, ofd) in replacement.ofd_table.iter() { + if !manages_ofd(ofd.file_type, ofd.host_handle) { + continue; + } + let Some((idx, _)) = old_backings + .iter() + .enumerate() + .find(|(idx, key)| !retained[*idx] && **key == (ofd.file_type, ofd.host_handle)) + else { + return Err(Errno::EBADF); + }; + retained[idx] = true; + } + + Ok(old_backings + .into_iter() + .zip(retained) + .filter_map(|(key, retained)| (!retained).then_some(key)) + .collect()) +} + +#[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) +)] +pub fn release_backings(backings: &[(FileType, i64)]) { + for &(file_type, host_handle) in backings { + release_for_ofd(file_type, host_handle); + } +} + +/// Read the authoritative open-file-description cursor. Memfd and procfs +/// cursors live in their shared backing; all other OFDs use their local field. +pub fn current_offset( + file_type: FileType, + host_handle: i64, + local_offset: i64, +) -> Result { + match file_type { + FileType::MemFd => with_memfds(|table| { + table + .get(negative_handle_idx(host_handle)?) + .map(|backing| backing.offset) + .ok_or(Errno::EBADF) + }), + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| { + table + .get(crate::procfs::procfs_buf_idx(host_handle)) + .map(|backing| backing.offset) + .ok_or(Errno::EBADF) + }) + } + _ => Ok(local_offset), + } +} + +/// Set the authoritative cursor. Returns true for shared-cursor OFDs so the +/// caller knows the local `OpenFileDesc::offset` field is only a wire-format +/// placeholder and must not become a second authority. +pub fn set_current_offset( + file_type: FileType, + host_handle: i64, + offset: i64, +) -> Result { + match file_type { + FileType::MemFd => { + with_memfds(|table| { + let backing = table + .get_mut(negative_handle_idx(host_handle)?) + .ok_or(Errno::EBADF)?; + backing.offset = offset; + Ok(()) + })?; + Ok(true) + } + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| { + let backing = table + .get_mut(crate::procfs::procfs_buf_idx(host_handle)) + .ok_or(Errno::EBADF)?; + backing.offset = offset; + Ok(()) + })?; + Ok(true) + } + _ => Ok(false), + } +} + +/// Add the child's one-per-OFD ownership reference when an OFD is inherited. +/// Returns `Ok(false)` for descriptor types without a backing in this module. +pub fn add_ref_for_ofd(file_type: FileType, host_handle: i64) -> Result { + match file_type { + FileType::EventFd => { + with_eventfds(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } + FileType::TimerFd => { + with_timerfds(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } + FileType::SignalFd => { + with_signalfds(|table| table.add_ref(negative_handle_idx(host_handle)?))? + } + FileType::MemFd => with_memfds(|table| table.add_ref(negative_handle_idx(host_handle)?))?, + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| table.add_ref(crate::procfs::procfs_buf_idx(host_handle)))? + } + _ => return Ok(false), + } + Ok(true) +} + +/// Drop one owning OFD reference. Returns true when this module owns the +/// descriptor type, including when a corrupt/stale handle had no live entry. +pub fn release_for_ofd(file_type: FileType, host_handle: i64) -> bool { + match file_type { + FileType::EventFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_eventfds(|table| table.release(idx)); + } + true + } + FileType::TimerFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_timerfds(|table| table.release(idx)); + } + true + } + FileType::SignalFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_signalfds(|table| table.release(idx)); + } + true + } + FileType::MemFd => { + if let Ok(idx) = negative_handle_idx(host_handle) { + with_memfds(|table| table.release(idx)); + } + true + } + FileType::Regular if crate::procfs::is_procfs_buf_handle(host_handle) => { + with_procfs_bufs(|table| table.release(crate::procfs::procfs_buf_idx(host_handle))); + true + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::{GlobalBackingTable, SharedBackingTable}; + + #[test] + fn shared_backing_reuses_only_freed_slots() { + let mut table = SharedBackingTable::new(); + let first = table.alloc(10u32); + assert_eq!(first, 0); + table.add_ref(first).unwrap(); + assert!(!table.release(first)); + + let second = table.alloc(20u32); + assert_eq!(second, 1, "live backing must retain its stable index"); + assert!(table.release(first)); + let reused = table.alloc(30u32); + assert_eq!(reused, first); + assert_eq!(table.get(reused), Some(&30)); + } + + #[test] + fn global_backing_closure_serializes_parallel_access() { + let table: &'static GlobalBackingTable = + Box::leak(Box::new(GlobalBackingTable::new())); + let threads: Vec<_> = (0..8u64) + .map(|thread_id| { + std::thread::spawn(move || { + for iteration in 0..500u64 { + let value = (thread_id << 32) | iteration; + let idx = table.with(|entries| entries.alloc(value)); + assert_eq!(table.with(|entries| entries.get(idx).copied()), Some(value)); + assert!(table.with(|entries| entries.release(idx))); + } + }) + }) + .collect(); + for thread in threads { + thread.join().unwrap(); + } + } +} diff --git a/crates/kernel/src/fork.rs b/crates/kernel/src/fork.rs index 087c47e4b9..323e726b0f 100644 --- a/crates/kernel/src/fork.rs +++ b/crates/kernel/src/fork.rs @@ -17,26 +17,26 @@ extern crate alloc; -use alloc::collections::BTreeSet; +use alloc::collections::{BTreeMap, BTreeSet}; use alloc::vec::Vec; use wasm_posix_shared::Errno; -use wasm_posix_shared::fd_flags::FD_CLOEXEC; +use wasm_posix_shared::fd_flags::{FD_CLOEXEC, FD_CLOFORK}; use crate::fd::{FdEntry, FdTable, OpenFileDescRef}; use crate::lock::LockTable; use crate::memory::{MappedRegion, MemoryLayoutMetadata, MemoryManager}; use crate::ofd::{FileType, OfdTable, OpenFileDesc}; use crate::process::{Process, ProcessState}; -use crate::signal::{SignalAction, SignalHandler, SignalState}; +use crate::signal::{PerThreadSignalState, RtSigEntry, SignalAction, SignalHandler, SignalState}; use crate::socket::SocketTable; use crate::terminal::{NCCS, TerminalState, WinSize}; const FORK_MAGIC: u32 = 0x464F524B; // "FORK" const EXEC_MAGIC: u32 = 0x45584543; // "EXEC" -// v9 adds the per-OFD DRI sidecar block: a u8 variant tag followed by -// `DriFdState` / `KmsFdState` / `PrimeBoState` payload as appropriate. -// See `write_dri_state` / `read_dri_state` below. -const FORK_VERSION: u32 = 9; +// v11 keeps the main thread's directed pending queue distinct from the shared +// process queue across legacy exec. Fork children still start with no pending +// signals, so the fork payload itself needs no new directed-signal section. +const FORK_VERSION: u32 = 11; // Bounds for deserialization to prevent OOM from malformed buffers. const MAX_FDS: u32 = 65536; @@ -45,6 +45,14 @@ const MAX_ENV_VARS: u32 = 65536; const MAX_ARGV: u32 = 65536; const MAX_PATH_LEN: usize = 1048576; // 1 MiB const MAX_STRING_LEN: usize = 1048576; // 1 MiB +const MAX_SOCKET_SLOTS: usize = 65536; +const MAX_SOCKET_OPTIONS: usize = 4096; +const MAX_SOCKET_STRING_LEN: usize = 256; +const MAX_IPV4_MULTICAST_MEMBERSHIPS: usize = 4096; +const MAX_IPV4_MULTICAST_SOURCES: usize = 4096; +const MAX_DIRECTED_SIGNAL_QUEUE: u32 = 65536; +const INITIAL_EXEC_STATE_BUFFER_LEN: usize = 64 * 1024; +const MAX_EXEC_STATE_BUFFER_LEN: usize = 4 * 1024 * 1024; // ── Writer helper ─────────────────────────────────────────────────────────── @@ -231,6 +239,209 @@ impl<'a> Reader<'a> { } } +fn write_directed_signal_state( + w: &mut Writer<'_>, + state: &PerThreadSignalState, +) -> Result<(), Errno> { + w.write_u64(state.pending)?; + let count = u32::try_from(state.rt_queue.len()).map_err(|_| Errno::E2BIG)?; + if count > MAX_DIRECTED_SIGNAL_QUEUE { + return Err(Errno::E2BIG); + } + w.write_u32(count)?; + for entry in &state.rt_queue { + w.write_u32(entry.signum)?; + w.write_i32(entry.si_value)?; + w.write_i32(entry.si_code)?; + w.write_i32(entry.timer_id.map(|id| id as i32).unwrap_or(-1))?; + } + Ok(()) +} + +fn read_directed_signal_state(r: &mut Reader<'_>) -> Result { + let pending = r.read_u64()?; + let count = r.read_u32()?; + if count > MAX_DIRECTED_SIGNAL_QUEUE { + return Err(Errno::EINVAL); + } + let mut state = PerThreadSignalState::new(); + state.pending = pending; + for _ in 0..count { + let signum = r.read_u32()?; + if signum == 0 + || signum >= wasm_posix_shared::signal::NSIG + || pending & crate::signal::sig_bit(signum) == 0 + { + return Err(Errno::EINVAL); + } + let si_value = r.read_i32()?; + let si_code = r.read_i32()?; + let timer_id = match r.read_i32()? { + -1 => None, + id if id >= 0 => Some(id as u32), + _ => return Err(Errno::EINVAL), + }; + state.rt_queue.push_back(RtSigEntry { + signum, + si_value, + si_code, + timer_id, + }); + } + Ok(state) +} + +fn discard_legacy_exec_timer_notifications(state: &mut PerThreadSignalState) { + let timer_signums: Vec = state + .rt_queue + .iter() + .filter(|entry| entry.timer_id.is_some()) + .map(|entry| entry.signum) + .collect(); + state.rt_queue.retain(|entry| entry.timer_id.is_none()); + for signum in timer_signums { + if !state.rt_queue.iter().any(|entry| entry.signum == signum) { + state.pending &= !crate::signal::sig_bit(signum); + } + } +} + +fn write_bounded_len(w: &mut Writer<'_>, len: usize, max: usize) -> Result<(), Errno> { + if len > max || len > u32::MAX as usize { + return Err(Errno::EINVAL); + } + w.write_u32(len as u32) +} + +fn read_bounded_count(r: &mut Reader<'_>, max: usize) -> Result { + let count = r.read_u32()? as usize; + if count > max { + return Err(Errno::EINVAL); + } + Ok(count) +} + +fn read_ipv4_addr(r: &mut Reader<'_>) -> Result<[u8; 4], Errno> { + let mut addr = [0u8; 4]; + addr.copy_from_slice(r.read_bytes(4)?); + Ok(addr) +} + +fn write_ipv4_source_list(w: &mut Writer<'_>, sources: &[[u8; 4]]) -> Result<(), Errno> { + write_bounded_len(w, sources.len(), MAX_IPV4_MULTICAST_SOURCES)?; + for source in sources { + w.write_bytes(source)?; + } + Ok(()) +} + +fn read_ipv4_source_list(r: &mut Reader<'_>) -> Result, Errno> { + let count = read_bounded_count(r, MAX_IPV4_MULTICAST_SOURCES)?; + let encoded_len = count.checked_mul(4).ok_or(Errno::EINVAL)?; + if r.remaining() < encoded_len { + return Err(Errno::EINVAL); + } + let mut sources = Vec::with_capacity(count); + for _ in 0..count { + sources.push(read_ipv4_addr(r)?); + } + Ok(sources) +} + +/// Write socket fields that are durable across fork but were added after the +/// original v4 socket block. Consume-once queues remain intentionally absent. +fn write_durable_socket_state( + w: &mut Writer<'_>, + sock: &crate::socket::SocketInfo, +) -> Result<(), Errno> { + w.write_bytes(&sock.bind_addr6)?; + w.write_bytes(&sock.peer_addr6)?; + w.write_i32(sock.linger_onoff)?; + w.write_i32(sock.linger_seconds)?; + w.write_u64(sock.recv_timeout_us)?; + w.write_u64(sock.send_timeout_us)?; + + match &sock.bind_device { + Some(device) => { + write_bounded_len(w, device.len(), MAX_SOCKET_STRING_LEN)?; + w.write_bytes(device)?; + } + None => w.write_u32(u32::MAX)?, + } + + write_bounded_len(w, sock.tcp_congestion.len(), MAX_SOCKET_STRING_LEN)?; + w.write_bytes(&sock.tcp_congestion)?; + + write_bounded_len( + w, + sock.ipv4_multicast_memberships.len(), + MAX_IPV4_MULTICAST_MEMBERSHIPS, + )?; + for membership in &sock.ipv4_multicast_memberships { + w.write_bytes(&membership.group)?; + w.write_bytes(&membership.interface_addr)?; + w.write_u32(u32::from(membership.any_source))?; + write_ipv4_source_list(w, &membership.blocked_sources)?; + write_ipv4_source_list(w, &membership.included_sources)?; + } + Ok(()) +} + +fn read_durable_socket_state( + r: &mut Reader<'_>, + sock: &mut crate::socket::SocketInfo, +) -> Result<(), Errno> { + sock.bind_addr6.copy_from_slice(r.read_bytes(16)?); + sock.peer_addr6.copy_from_slice(r.read_bytes(16)?); + sock.linger_onoff = r.read_i32()?; + sock.linger_seconds = r.read_i32()?; + sock.recv_timeout_us = r.read_u64()?; + sock.send_timeout_us = r.read_u64()?; + + let bind_device_len = r.read_u32()?; + sock.bind_device = if bind_device_len == u32::MAX { + None + } else { + Some( + r.read_bounded_bytes(bind_device_len as usize, MAX_SOCKET_STRING_LEN)? + .to_vec(), + ) + }; + + let congestion_len = read_bounded_count(r, MAX_SOCKET_STRING_LEN)?; + sock.tcp_congestion = r + .read_bounded_bytes(congestion_len, MAX_SOCKET_STRING_LEN)? + .to_vec(); + + let membership_count = read_bounded_count(r, MAX_IPV4_MULTICAST_MEMBERSHIPS)?; + // Each membership has at least 20 encoded bytes before any source entries. + let minimum_len = membership_count.checked_mul(20).ok_or(Errno::EINVAL)?; + if r.remaining() < minimum_len { + return Err(Errno::EINVAL); + } + let mut memberships = Vec::with_capacity(membership_count); + for _ in 0..membership_count { + let group = read_ipv4_addr(r)?; + let interface_addr = read_ipv4_addr(r)?; + let any_source = match r.read_u32()? { + 0 => false, + 1 => true, + _ => return Err(Errno::EINVAL), + }; + let blocked_sources = read_ipv4_source_list(r)?; + let included_sources = read_ipv4_source_list(r)?; + memberships.push(crate::socket::Ipv4MulticastMembership { + group, + interface_addr, + any_source, + blocked_sources, + included_sources, + }); + } + sock.ipv4_multicast_memberships = memberships; + Ok(()) +} + // ── FileType encoding ─────────────────────────────────────────────────────── fn file_type_to_u32(ft: FileType) -> u32 { @@ -535,7 +746,15 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result = proc.fd_table.iter().collect(); + let fd_entries: Vec<(i32, &FdEntry)> = proc + .fd_table + .iter() + .filter(|(_, entry)| entry.fd_flags & FD_CLOFORK == 0) + .collect(); + let mut inherited_ofd_refs: BTreeMap = BTreeMap::new(); + for (_, entry) in &fd_entries { + *inherited_ofd_refs.entry(entry.ofd_ref.0).or_insert(0) += 1; + } w.write_u32(fd_entries.len() as u32)?; for (fd_num, entry) in &fd_entries { w.write_u32(*fd_num as u32)?; @@ -544,7 +763,11 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result = proc.ofd_table.iter().collect(); + let ofd_entries: Vec<(usize, &OpenFileDesc)> = proc + .ofd_table + .iter() + .filter(|(index, _)| inherited_ofd_refs.contains_key(index)) + .collect(); w.write_u32(ofd_entries.len() as u32)?; for (index, ofd) in &ofd_entries { w.write_u32(*index as u32)?; @@ -552,7 +775,7 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result MAX_SOCKET_SLOTS { + return Err(Errno::EINVAL); + } // Count actual sockets let mut sock_count = 0u32; for idx in 0..proc.sockets.len() { @@ -703,6 +929,9 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result MAX_SOCKET_OPTIONS { + return Err(Errno::EINVAL); + } w.write_u32(sock.options.len() as u32)?; for &(level, optname, value) in &sock.options { w.write_u32(level)?; @@ -723,7 +952,7 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result { - w.write_u32(p.len() as u32)?; + write_bounded_len(&mut w, p.len(), MAX_PATH_LEN)?; w.write_bytes(p)?; } None => { @@ -744,6 +973,7 @@ pub fn serialize_fork_state(proc: &Process, buf: &mut [u8]) -> Result Result= 8 { use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; - let _total_slots = r.read_u32()? as usize; + let total_slots = r.read_u32()? as usize; let sock_count = r.read_u32()? as usize; + if total_slots > MAX_SOCKET_SLOTS || sock_count > total_slots { + return Err(Errno::EINVAL); + } + let mut seen_socket_indices = BTreeSet::new(); for _ in 0..sock_count { let idx = r.read_u32()? as usize; + if idx >= total_slots || !seen_socket_indices.insert(idx) { + return Err(Errno::EINVAL); + } let domain = match r.read_u32()? { 0 => SocketDomain::Unix, 1 => SocketDomain::Inet, @@ -1087,8 +1324,12 @@ pub fn deserialize_fork_state(buf: &[u8], child_pid: u32) -> Result Result total_slots || r.remaining() < backlog_len { + return Err(Errno::EINVAL); + } for _ in 0..bl_count { let _ = r.read_u32()?; } @@ -1129,7 +1372,7 @@ pub fn deserialize_fork_state(buf: &[u8], child_pid: u32) -> Result Result= 4 { - let bp_len = r.read_u32()?; - if bp_len != 0xFFFFFFFF { - let bp = r.read_bytes(bp_len as usize)?; - sock.bind_path = Some(bp.to_vec()); - } - } - if r.remaining() >= 4 { - let aw_raw = r.read_u32()?; - sock.accept_wake_idx = if aw_raw == 0xFFFFFFFF { - None - } else { - Some(aw_raw) - }; + let bp_len = r.read_u32()?; + if bp_len != 0xFFFFFFFF { + let bp = r.read_bounded_bytes(bp_len as usize, MAX_PATH_LEN)?; + sock.bind_path = Some(bp.to_vec()); } + let aw_raw = r.read_u32()?; + sock.accept_wake_idx = if aw_raw == 0xFFFFFFFF { + None + } else { + Some(aw_raw) + }; + read_durable_socket_state(&mut r, &mut sock)?; sockets.insert_at(idx, sock); } } @@ -1176,6 +1416,9 @@ pub fn deserialize_fork_state(buf: &[u8], child_pid: u32) -> Result Result Result Result = proc @@ -1293,11 +1533,13 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result = fd_entries - .iter() - .map(|(_, entry)| entry.ofd_ref.0) - .collect(); + // Recompute local OFD references from the surviving fd aliases. A + // CLOEXEC alias must not leave the replacement process with the parent's + // stale ref_count, otherwise its eventual last close cannot free the OFD. + let mut surviving_ofd_refs: BTreeMap = BTreeMap::new(); + for (_, entry) in &fd_entries { + *surviving_ofd_refs.entry(entry.ofd_ref.0).or_insert(0) += 1; + } w.write_u32(proc.fd_table.max_fds() as u32)?; w.write_u32(fd_entries.len() as u32)?; @@ -1311,7 +1553,7 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result = proc .ofd_table .iter() - .filter(|(index, _)| referenced_ofds.contains(index)) + .filter(|(index, _)| surviving_ofd_refs.contains_key(index)) .collect(); w.write_u32(ofd_entries.len() as u32)?; for (index, ofd) in &ofd_entries { @@ -1320,7 +1562,7 @@ pub fn serialize_exec_state(proc: &Process, buf: &mut [u8]) -> Result Result Result, Errno> { + let mut len = INITIAL_EXEC_STATE_BUFFER_LEN; + + loop { + let mut buf = alloc::vec![0u8; len]; + match serialize_exec_state(proc, &mut buf) { + Ok(written) => { + buf.truncate(written); + return Ok(buf); + } + Err(Errno::ENOMEM) if len < MAX_EXEC_STATE_BUFFER_LEN => { + len = len.saturating_mul(2).min(MAX_EXEC_STATE_BUFFER_LEN); + } + Err(err) => return Err(err), + } + } +} + // ── Exec Deserialize ──────────────────────────────────────────────────────── /// Deserialize process state from an exec buffer. @@ -1429,6 +1693,10 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { // Read pending signals (exec preserves them, unlike fork) let pending = r.read_u64()?; let signals = SignalState::from_parts_with_pending(handlers, blocked, pending); + let mut main_thread_signals = read_directed_signal_state(&mut r)?; + // POSIX timer objects do not survive exec. The legacy serialized exec path + // must not retain directed notifications that refer to discarded timers. + discard_legacy_exec_timer_notifications(&mut main_thread_signals); // ── FD table ── let max_fds = r.read_u32()? as usize; @@ -1583,6 +1851,9 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { is_session_leader, state: ProcessState::Running, exit_status: 0, + exit_signal: 0, + // ProcessTable preserves the old process's record after legacy exec. + wait_event: None, fd_table, ofd_table, lock_table: LockTable::new(), @@ -1591,6 +1862,7 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { cwd, dir_streams: Vec::new(), signals, + main_thread_signals, memory, terminal, environ, @@ -1609,18 +1881,13 @@ pub fn deserialize_exec_state(buf: &[u8], pid: u32) -> Result { next_ephemeral_port: 49152, threads: Vec::new(), // exec resets to single thread next_tid: 0, - eventfds: Vec::new(), epolls: Vec::new(), - timerfds: Vec::new(), - signalfds: Vec::new(), posix_timers: Vec::new(), alt_stack_sp: 0, alt_stack_flags: 2, // SS_DISABLE alt_stack_size: 0, alt_stack_depth: 0, fork_pipe_replay: Vec::new(), - memfds: Vec::new(), - procfs_bufs: Vec::new(), has_exec: false, // exec wipes any prior framebuffer binding — the new program // must open and mmap /dev/fb0 itself. @@ -1652,6 +1919,7 @@ mod tests { assert_eq!(&buf[0..4], &0x464F524Bu32.to_le_bytes()); let child = deserialize_fork_state(&buf[..written], 42).unwrap(); + assert!(child.wait_event.is_none()); assert_eq!(child.pid, 42); assert_eq!(child.ppid, proc.pid); // child's ppid is parent's pid assert_eq!(child.uid, proc.uid); @@ -1660,6 +1928,7 @@ mod tests { assert_eq!(child.nice, proc.nice); assert_eq!(child.cwd, proc.cwd); assert_eq!(child.signals.pending, 0); + assert_eq!(child.main_thread_signals.pending, 0); } #[test] @@ -1709,6 +1978,7 @@ mod tests { .set_handler(15, SignalHandler::Handler(42)) .unwrap(); proc.signals.blocked = 0x0000_0004; + proc.main_thread_signals.raise(25); let mut buf = vec![0u8; 64 * 1024]; let written = serialize_fork_state(&proc, &mut buf).unwrap(); @@ -1718,6 +1988,8 @@ mod tests { assert_eq!(child.signals.get_handler(15), SignalHandler::Handler(42)); assert_eq!(child.signals.blocked, 0x0000_0004); assert_eq!(child.signals.pending, 0); + assert_eq!(child.main_thread_signals.pending, 0); + assert_ne!(proc.main_thread_signals.pending, 0); } #[test] @@ -1804,6 +2076,71 @@ mod tests { assert_eq!(restored.pid, 1); assert_eq!(restored.ppid, 0); // default ppid assert_eq!(restored.signals.pending, 0); + assert_eq!(restored.main_thread_signals.pending, 0); + } + + #[test] + fn test_exec_preserves_main_directed_queue_separately() { + let mut proc = Process::new(1); + proc.main_thread_signals.raise(25); + proc.main_thread_signals.raise_with_value(32, 101); + proc.main_thread_signals.raise_with_value(32, 202); + proc.main_thread_signals.raise_timer(10, 303, 7); + + let serialized = serialize_exec_state_with_growing_buffer(&proc).unwrap(); + let mut restored = deserialize_exec_state(&serialized, proc.pid).unwrap(); + + assert_eq!(restored.signals.pending, 0); + assert!(restored.main_thread_signals.pending != 0); + assert!(!restored.main_thread_signals.is_pending(10)); + assert_eq!(restored.main_thread_signals.consume_one(25), Some((0, 0))); + assert_eq!( + restored.main_thread_signals.consume_one(32), + Some((101, -1)) + ); + assert_eq!( + restored.main_thread_signals.consume_one(32), + Some((202, -1)) + ); + assert_eq!(restored.main_thread_signals.pending, 0); + } + + #[test] + fn test_exec_rejects_oversized_main_directed_queue() { + let mut proc = Process::new(1); + for value in 0..=MAX_DIRECTED_SIGNAL_QUEUE { + assert!(proc + .main_thread_signals + .raise_with_value(32, value as i32)); + } + + assert_eq!( + serialize_exec_state_with_growing_buffer(&proc), + Err(Errno::E2BIG) + ); + } + + #[test] + fn test_exec_state_grows_for_large_environment() { + let mut proc = Process::new(1); + proc.environ = (0..1200) + .map(|_| { + let mut var = b"KDE_LONG_ENV=".to_vec(); + var.extend(core::iter::repeat_n(b'x', 80)); + var + }) + .collect(); + + let mut old_limit_buf = alloc::vec![0u8; INITIAL_EXEC_STATE_BUFFER_LEN]; + assert_eq!( + serialize_exec_state(&proc, &mut old_limit_buf), + Err(Errno::ENOMEM), + ); + + let serialized = serialize_exec_state_with_growing_buffer(&proc).unwrap(); + assert!(serialized.len() > INITIAL_EXEC_STATE_BUFFER_LEN); + let restored = deserialize_exec_state(&serialized, 1).unwrap(); + assert_eq!(restored.environ, proc.environ); } #[test] @@ -1830,6 +2167,35 @@ mod tests { assert!(restored.fd_table.get(0).is_ok()); } + #[test] + fn exec_recomputes_ofd_ref_count_after_filtering_cloexec_alias() { + use wasm_posix_shared::fd_flags::FD_CLOEXEC; + + let mut proc = Process::new(1); + let ofd_idx = proc.ofd_table.create( + crate::ofd::FileType::Regular, + 0, + 100, + b"/test/aliased".to_vec(), + ); + let retained_fd = proc + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + proc.ofd_table.inc_ref(ofd_idx); + let cloexec_fd = proc + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), FD_CLOEXEC) + .unwrap(); + assert_eq!(proc.ofd_table.get(ofd_idx).unwrap().ref_count, 2); + + let serialized = serialize_exec_state_with_growing_buffer(&proc).unwrap(); + let restored = deserialize_exec_state(&serialized, proc.pid).unwrap(); + assert!(restored.fd_table.get(retained_fd).is_ok()); + assert!(restored.fd_table.get(cloexec_fd).is_err()); + assert_eq!(restored.ofd_table.get(ofd_idx).unwrap().ref_count, 1); + } + #[test] fn test_exec_state_resets_caught_handler_preserves_ignore() { let mut proc = Process::new(1); @@ -1884,6 +2250,133 @@ mod tests { assert_eq!(child.memory.get_brk(), 0x02000000); } + #[test] + fn test_fork_roundtrips_durable_ipv6_and_multicast_socket_state() { + use crate::socket::{ + Ipv4MulticastMembership, SocketDomain, SocketInfo, SocketState, SocketType, + }; + + let mut proc = Process::new(1); + let mut socket = SocketInfo::new(SocketDomain::Inet6, SocketType::Dgram, 17); + let mut bind_addr6 = [0u8; 16]; + bind_addr6[..4].copy_from_slice(&[0x20, 0x01, 0x0d, 0xb8]); + bind_addr6[15] = 1; + let mut peer_addr6 = bind_addr6; + peer_addr6[15] = 2; + socket.state = SocketState::Connected; + socket.bind_addr6 = bind_addr6; + socket.peer_addr6 = peer_addr6; + socket.bind_port = 41000; + socket.peer_port = 42000; + socket.linger_onoff = 1; + socket.linger_seconds = 30; + socket.recv_timeout_us = 1_250_000; + socket.send_timeout_us = 2_500_000; + socket.bind_device = Some(b"lo".to_vec()); + socket.tcp_congestion = b"reno".to_vec(); + socket.ipv4_multicast_memberships = vec![ + Ipv4MulticastMembership { + group: [239, 1, 2, 3], + interface_addr: [127, 0, 0, 1], + any_source: true, + blocked_sources: vec![[127, 0, 0, 9], [127, 0, 0, 10]], + included_sources: vec![], + }, + Ipv4MulticastMembership { + group: [232, 4, 5, 6], + interface_addr: [10, 88, 0, 2], + any_source: false, + blocked_sources: vec![], + included_sources: vec![[10, 88, 0, 3], [10, 88, 0, 4]], + }, + ]; + let socket_idx = proc.sockets.alloc(socket); + + let mut buf = vec![0u8; 64 * 1024]; + let written = serialize_fork_state(&proc, &mut buf).unwrap(); + let child = deserialize_fork_state(&buf[..written], 42).unwrap(); + let inherited = child.sockets.get(socket_idx).unwrap(); + + assert_eq!(inherited.state, SocketState::Connected); + assert_eq!(inherited.bind_addr6, bind_addr6); + assert_eq!(inherited.peer_addr6, peer_addr6); + assert_eq!(inherited.bind_port, 41000); + assert_eq!(inherited.peer_port, 42000); + assert_eq!((inherited.linger_onoff, inherited.linger_seconds), (1, 30)); + assert_eq!(inherited.recv_timeout_us, 1_250_000); + assert_eq!(inherited.send_timeout_us, 2_500_000); + assert_eq!(inherited.bind_device.as_deref(), Some(b"lo".as_slice())); + assert_eq!(inherited.tcp_congestion, b"reno"); + assert_eq!( + inherited.ipv4_multicast_memberships, + vec![ + Ipv4MulticastMembership { + group: [239, 1, 2, 3], + interface_addr: [127, 0, 0, 1], + any_source: true, + blocked_sources: vec![[127, 0, 0, 9], [127, 0, 0, 10]], + included_sources: vec![], + }, + Ipv4MulticastMembership { + group: [232, 4, 5, 6], + interface_addr: [10, 88, 0, 2], + any_source: false, + blocked_sources: vec![], + included_sources: vec![[10, 88, 0, 3], [10, 88, 0, 4]], + }, + ] + ); + } + + #[test] + fn test_durable_socket_state_rejects_oversized_strings_and_sources() { + use crate::socket::{Ipv4MulticastMembership, SocketDomain, SocketInfo, SocketType}; + + let mut proc = Process::new(1); + let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Dgram, 17); + socket.bind_device = Some(vec![b'x'; MAX_SOCKET_STRING_LEN + 1]); + proc.sockets.alloc(socket); + let mut buf = vec![0u8; 64 * 1024]; + assert_eq!(serialize_fork_state(&proc, &mut buf), Err(Errno::EINVAL)); + + let mut proc = Process::new(1); + let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Dgram, 17); + socket.ipv4_multicast_memberships = vec![Ipv4MulticastMembership { + group: [239, 1, 2, 3], + interface_addr: [127, 0, 0, 1], + any_source: true, + blocked_sources: vec![[127, 0, 0, 2]; MAX_IPV4_MULTICAST_SOURCES + 1], + included_sources: vec![], + }]; + proc.sockets.alloc(socket); + assert_eq!(serialize_fork_state(&proc, &mut buf), Err(Errno::EINVAL)); + } + + #[test] + fn test_durable_socket_state_rejects_malformed_encoded_lengths() { + use crate::socket::{SocketDomain, SocketInfo, SocketType}; + + let mut encoded = [0u8; 64]; + let mut writer = Writer::new(&mut encoded); + writer.write_bytes(&[0; 16]).unwrap(); // bind_addr6 + writer.write_bytes(&[0; 16]).unwrap(); // peer_addr6 + writer.write_i32(0).unwrap(); // linger_onoff + writer.write_i32(0).unwrap(); // linger_seconds + writer.write_u64(0).unwrap(); // recv_timeout_us + writer.write_u64(0).unwrap(); // send_timeout_us + writer + .write_u32((MAX_SOCKET_STRING_LEN + 1) as u32) + .unwrap(); + let written = writer.pos; + + let mut reader = Reader::new(&encoded[..written]); + let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Dgram, 17); + assert_eq!( + read_durable_socket_state(&mut reader, &mut socket), + Err(Errno::EINVAL) + ); + } + #[test] fn test_fork_inherits_compact_memory_layout() { use wasm_posix_shared::mmap::*; diff --git a/crates/kernel/src/lib.rs b/crates/kernel/src/lib.rs index e8f0271bbc..bedaf8296c 100644 --- a/crates/kernel/src/lib.rs +++ b/crates/kernel/src/lib.rs @@ -6,6 +6,7 @@ extern crate alloc; extern crate wasm_posix_shared; pub mod audio; +pub(crate) mod descriptor_backing; pub mod devfs; pub mod dri; pub mod fd; diff --git a/crates/kernel/src/memory.rs b/crates/kernel/src/memory.rs index d63cb47ac3..b7808b0a5e 100644 --- a/crates/kernel/src/memory.rs +++ b/crates/kernel/src/memory.rs @@ -92,7 +92,11 @@ impl MemoryManager { } /// Restore mmap mappings from fork (used by deserialize_fork_state). - pub fn set_mappings(&mut self, mappings: Vec) { + pub fn set_mappings(&mut self, mut mappings: Vec) { + // Ordinary mmap/munmap mutations preserve address order. Restore that + // invariant explicitly at the fork-state boundary so later first-fit + // scans do not depend on serialized input order. + mappings.sort_by_key(|mapping| mapping.addr); self.mappings = mappings; } @@ -133,9 +137,23 @@ impl MemoryManager { }); hint } else { - // Find first gap in [mmap_base, max_addr) that fits aligned_len. - // Mappings are kept sorted by address. - match self.find_gap(aligned_len) { + // A non-null address without MAP_FIXED is a placement hint. Wasm + // mappings use 64 KiB pages, so mirror mmap's page-boundary + // behavior by rounding the hint down and using it only when the + // complete range is available. An unusable hint falls back to + // the ordinary first-fit search without replacing anything. + let rounded_hint = hint & !0xFFFF; + let hinted_addr = if rounded_hint >= self.mmap_base.max(self.program_break) + && self.can_grow_at(rounded_hint, aligned_len) + { + Some(rounded_hint) + } else { + None + }; + + // Find the first gap in [mmap_base, max_addr) when the hint is + // absent or unusable. Mappings are kept sorted by address. + match hinted_addr.or_else(|| self.find_gap(aligned_len)) { Some(a) => a, None => return wasm_posix_shared::mmap::MAP_FAILED, } @@ -159,13 +177,43 @@ impl MemoryManager { /// Find the first gap in [mmap_base, max_addr) that can fit `needed` bytes. fn find_gap(&self, needed: usize) -> Option { let mut cursor = self.mmap_base.max(self.program_break); - let mut occupied: Vec<(usize, usize)> = - Vec::with_capacity(self.mappings.len() + self.reserved_regions.len()); - occupied.extend(self.mappings.iter().map(|m| (m.addr, m.len))); - occupied.extend(self.reserved_regions.iter().map(|r| (r.addr, r.len))); - occupied.sort_by_key(|(addr, _)| *addr); - for (addr, len) in occupied { + // Both collections are maintained in address order. Merge them as two + // sorted streams so the first-fit decision is identical to scanning a + // combined sorted list without materializing that temporary list. + let mut mapping_idx = 0; + let mut reserved_idx = 0; + loop { + let next_mapping = self + .mappings + .get(mapping_idx) + .map(|mapping| (mapping.addr, mapping.len, true)); + let next_reserved = self + .reserved_regions + .get(reserved_idx) + .map(|reserved| (reserved.addr, reserved.len, false)); + let Some((addr, len, is_mapping)) = (match (next_mapping, next_reserved) { + (Some(mapping), Some(reserved)) => { + // The old stable address sort saw mappings before reserved + // regions at an equal start address. + if mapping.0 <= reserved.0 { + Some(mapping) + } else { + Some(reserved) + } + } + (Some(mapping), None) => Some(mapping), + (None, Some(reserved)) => Some(reserved), + (None, None) => None, + }) else { + break; + }; + + if is_mapping { + mapping_idx += 1; + } else { + reserved_idx += 1; + } if addr < cursor { let end = addr.saturating_add(len); if end > cursor { @@ -267,7 +315,11 @@ impl MemoryManager { if len == 0 { return false; } - let unmap_end = addr.saturating_add(len); + let aligned_len = match len.checked_add(0xFFFF) { + Some(value) => value & !0xFFFF, + None => return false, + }; + let unmap_end = addr.saturating_add(aligned_len); let mut found = false; let mut new_mappings: Vec = Vec::new(); @@ -534,6 +586,42 @@ mod tests { use super::*; use wasm_posix_shared::mmap::*; + fn mapped_region(addr: usize, len: usize) -> MappedRegion { + MappedRegion { + addr, + len, + prot: PROT_READ | PROT_WRITE, + flags: MAP_PRIVATE | MAP_ANONYMOUS, + } + } + + /// Reference the former combined-list implementation so the stream merge + /// is checked against the exact first-fit policy it replaces. + fn reference_find_gap(mm: &MemoryManager, needed: usize) -> Option { + let mut cursor = mm.mmap_base.max(mm.program_break); + let mut occupied = Vec::with_capacity(mm.mappings.len() + mm.reserved_regions.len()); + occupied.extend(mm.mappings.iter().map(|mapping| (mapping.addr, mapping.len))); + occupied.extend( + mm.reserved_regions + .iter() + .map(|reserved| (reserved.addr, reserved.len)), + ); + occupied.sort_by_key(|(addr, _)| *addr); + + for (addr, len) in occupied { + if addr < cursor { + cursor = cursor.max(addr.saturating_add(len)); + continue; + } + if addr - cursor >= needed { + return Some(cursor); + } + cursor = cursor.max(addr.saturating_add(len)); + } + + (cursor.saturating_add(needed) <= mm.max_addr).then_some(cursor) + } + #[test] fn test_mmap_anonymous() { let mut mm = MemoryManager::new(); @@ -650,6 +738,159 @@ mod tests { assert_eq!(addr2, addr + 0x10000); } + #[test] + fn test_mmap_gap_stream_merge_matches_combined_reference() { + let page = 0x10000; + let base = MemoryManager::MMAP_BASE; + let layouts = [ + // Empty address space. + (vec![], vec![], base + 8 * page), + // Guest and host ranges interleave with exact and undersized gaps. + ( + vec![mapped_region(base, page), mapped_region(base + 4 * page, page)], + vec![ReservedRegion { + addr: base + 2 * page, + len: page, + }], + base + 8 * page, + ), + // Equal starts retain the former stable-sort order, while the + // longer overlapping reservation still advances the cursor. + ( + vec![mapped_region(base, page)], + vec![ReservedRegion { + addr: base, + len: 3 * page, + }], + base + 8 * page, + ), + // Occupancy beginning before mmap_base can overlap and extend + // through later entries from the other stream. + ( + vec![mapped_region(base - page, 3 * page)], + vec![ReservedRegion { + addr: base + page, + len: 3 * page, + }], + base + 8 * page, + ), + // No trailing range is large enough. + ( + vec![mapped_region(base, 2 * page)], + vec![ReservedRegion { + addr: base + 2 * page, + len: 2 * page, + }], + base + 4 * page, + ), + ]; + + for (case, (mappings, reserved_regions, max_addr)) in layouts.into_iter().enumerate() { + let mut mm = MemoryManager::new(); + mm.max_addr = max_addr; + mm.set_mappings(mappings); + mm.reserved_regions = reserved_regions; + mm.reserved_regions + .sort_unstable_by_key(|reserved| reserved.addr); + + for needed in [page, 2 * page, 3 * page] { + assert_eq!( + mm.find_gap(needed), + reference_find_gap(&mm, needed), + "layout {case}, needed {needed:#x}", + ); + } + } + + // Exhaust the relative ordering and overlap combinations for one + // range from each stream, including starts below mmap_base and equal + // starts. The table above covers multiple entries within one stream. + for mapping_slot in 0..=5 { + for mapping_pages in 1..=3 { + for reserved_slot in 0..=5 { + for reserved_pages in 1..=3 { + let mut mm = MemoryManager::new(); + mm.max_addr = base + 8 * page; + mm.set_mappings(vec![mapped_region( + base - page + mapping_slot * page, + mapping_pages * page, + )]); + mm.reserved_regions = vec![ReservedRegion { + addr: base - page + reserved_slot * page, + len: reserved_pages * page, + }]; + + for needed in [page, 2 * page, 3 * page] { + assert_eq!(mm.find_gap(needed), reference_find_gap(&mm, needed)); + } + } + } + } + } + } + + #[test] + fn test_set_mappings_restores_address_order() { + let page = 0x10000; + let base = MemoryManager::MMAP_BASE; + let mut mm = MemoryManager::new(); + + mm.set_mappings(vec![ + mapped_region(base + 2 * page, page), + mapped_region(base, page), + ]); + + assert_eq!( + mm.mappings() + .iter() + .map(|mapping| mapping.addr) + .collect::>(), + vec![base, base + 2 * page], + ); + assert_eq!(mm.find_gap(page), Some(base + page)); + } + + #[test] + fn test_mmap_non_fixed_prefers_free_address_hint() { + let mut mm = MemoryManager::new(); + let rw = PROT_READ | PROT_WRITE; + let anon = MAP_PRIVATE | MAP_ANONYMOUS; + let base = MemoryManager::MMAP_BASE; + + assert_eq!(mm.mmap_anonymous(base, 0x10000, rw, anon | MAP_FIXED), base); + assert_eq!( + mm.mmap_anonymous(base + 0x20000, 0x10000, rw, anon | MAP_FIXED), + base + 0x20000 + ); + + // Prefer a usable hint even though an earlier first-fit gap exists, + // and round an unaligned hint down to the Wasm page boundary. + assert_eq!( + mm.mmap_anonymous(base + 0x30042, 0x10000, rw, anon), + base + 0x30000 + ); + + // An occupied hint must not replace the existing mapping. + assert_eq!( + mm.mmap_anonymous(base + 0x20000, 0x10000, rw, anon), + base + 0x10000 + ); + assert!(mm.is_mapped(base + 0x20000)); + } + + #[test] + fn test_munmap_rounds_length_up_to_wasm_page() { + let mut mm = MemoryManager::new(); + let rw = PROT_READ | PROT_WRITE; + let anon = MAP_PRIVATE | MAP_ANONYMOUS; + let addr = mm.mmap_anonymous(0, 0x20000, rw, anon); + + assert!(mm.munmap(addr, 0x10001)); + assert!(!mm.is_mapped(addr)); + assert!(!mm.is_mapped(addr + 0x10000)); + assert_eq!(mm.mmap_anonymous(0, 0x20000, rw, anon), addr); + } + #[test] fn test_brk() { let mut mm = MemoryManager::new(); diff --git a/crates/kernel/src/mouse.rs b/crates/kernel/src/mouse.rs index 0a7e2d28d2..52d26a3225 100644 --- a/crates/kernel/src/mouse.rs +++ b/crates/kernel/src/mouse.rs @@ -138,8 +138,8 @@ pub fn has_data() -> bool { !queue().is_empty() } -/// Drop all queued events. Called on process exit / exec by the owner -/// so a fresh open by a successor sees an empty queue. +/// Drop all queued events when the owner exits or closes its last fd so a +/// fresh open by a successor sees an empty queue. pub fn reset() { queue().clear(); } diff --git a/crates/kernel/src/ofd.rs b/crates/kernel/src/ofd.rs index 072882db49..3546cfe57c 100644 --- a/crates/kernel/src/ofd.rs +++ b/crates/kernel/src/ofd.rs @@ -53,6 +53,11 @@ pub fn host_handle_close_ref(h: i64) -> bool { true } +#[cfg(test)] +pub fn host_handle_ref_count(h: i64) -> u32 { + get_host_handle_refs().get(&h).copied().unwrap_or(0) +} + /// The set of flags that F_SETFL is allowed to modify (POSIX semantics). const SETFL_MODIFIABLE: u32 = O_APPEND | O_NONBLOCK; diff --git a/crates/kernel/src/path.rs b/crates/kernel/src/path.rs index 7f7adfdd1d..dc59a47fa1 100644 --- a/crates/kernel/src/path.rs +++ b/crates/kernel/src/path.rs @@ -1,6 +1,29 @@ extern crate alloc; use alloc::vec::Vec; +/// Make a pathname absolute without interpreting any of its components. +/// +/// Pathname resolution is stateful: `missing/..` must look up `missing`, and +/// a `..` after a symlink applies to the symlink target rather than to the +/// spelling of the input. Callers that need POSIX resolution must therefore +/// preserve `.`, `..`, repeated separators, and a final slash until the +/// namespace walker has examined them. +pub fn make_absolute(path: &[u8], cwd: &[u8]) -> Vec { + if path.is_empty() { + return Vec::new(); + } + if path[0] == b'/' { + return path.to_vec(); + } + + let mut absolute = cwd.to_vec(); + if absolute.last() != Some(&b'/') { + absolute.push(b'/'); + } + absolute.extend_from_slice(path); + absolute +} + /// Resolve a path against a working directory. /// If path is absolute (starts with '/'), normalize and return it. /// If path is relative, prepend cwd + '/' and normalize. @@ -56,6 +79,16 @@ mod tests { assert_eq!(resolved, b"/home/user/file.txt"); } + #[test] + fn test_make_absolute_preserves_resolution_components() { + assert_eq!( + make_absolute(b"missing/../file/.", b"/working/dir"), + b"/working/dir/missing/../file/." + ); + assert_eq!(make_absolute(b"/a//b/../", b"/ignored"), b"/a//b/../"); + assert!(make_absolute(b"", b"/working/dir").is_empty()); + } + #[test] fn test_relative_path_prepends_cwd() { let resolved = resolve_path(b"file.txt", b"/working/dir"); diff --git a/crates/kernel/src/pipe.rs b/crates/kernel/src/pipe.rs index b4997da5aa..ec5e733f03 100644 --- a/crates/kernel/src/pipe.rs +++ b/crates/kernel/src/pipe.rs @@ -60,6 +60,11 @@ pub struct PipeBuffer { len: usize, read_count: u32, write_count: u32, + /// The receive half of a normally closed TCP endpoint remains as an + /// orphaned discard sink until the peer closes its write half. This models + /// TCP's simplex FIN without inventing a fixed number of successful writes + /// after EOF. + orphaned_read: bool, /// Index of this pipe in the PipeTable (for wakeup events). pipe_idx: u32, /// Ancillary data queue for SCM_RIGHTS FD passing. @@ -79,6 +84,7 @@ impl PipeBuffer { len: 0, read_count: 1, write_count: 1, + orphaned_read: false, pipe_idx: 0, ancillary_fds: VecDeque::new(), } @@ -109,6 +115,9 @@ impl PipeBuffer { /// Performs a partial write if the buffer does not have enough free space /// for all of `data`. Returns 0 if the buffer is full. pub fn write(&mut self, data: &[u8]) -> usize { + if self.read_count == 0 { + return if self.orphaned_read { data.len() } else { 0 }; + } let cap = self.capacity(); let n = data.len().min(self.free_space()); if n == 0 { @@ -178,19 +187,46 @@ impl PipeBuffer { /// Close one read end of the pipe. Decrements the read reference count. pub fn close_read_end(&mut self) { self.read_count = self.read_count.saturating_sub(1); + if self.read_count == 0 { + self.orphaned_read = false; + self.head = 0; + self.tail = 0; + self.len = 0; + } // Read end closed → pipe became writable (writers get EPIPE/SIGPIPE) crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); } + /// Close one TCP read end with orderly-close semantics. + /// + /// The last real reader becomes an orphaned discard sink while a writer is + /// still open. This is the pipe-backed equivalent of an operating system + /// retaining a TCP control block after the application closes its socket. + /// Explicit read shutdown uses `close_read_end` instead. + pub fn close_read_end_orderly(&mut self) { + self.read_count = self.read_count.saturating_sub(1); + if self.read_count == 0 { + self.head = 0; + self.tail = 0; + self.len = 0; + self.orphaned_read = self.write_count > 0; + } + crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_WRITABLE); + } + /// Close one write end of the pipe. Decrements the write reference count. pub fn close_write_end(&mut self) { self.write_count = self.write_count.saturating_sub(1); + if self.write_count == 0 { + self.orphaned_read = false; + } // Write end closed → pipe became readable (readers get EOF) crate::wakeup::push(self.pipe_idx, crate::wakeup::WAKE_READABLE); } /// Add a reader reference (e.g., after fork or dup). pub fn add_reader(&mut self) { + self.orphaned_read = false; self.read_count += 1; } @@ -201,6 +237,14 @@ impl PipeBuffer { /// Returns true if the read end is still open (any readers remain). pub fn is_read_end_open(&self) -> bool { + self.read_count > 0 || self.orphaned_read + } + + /// Returns true if an application-owned reader remains. + /// + /// Unlike `is_read_end_open`, this excludes TCP's orphaned discard sink so + /// host bridges can distinguish SHUT_WR from a final close. + pub fn has_readers(&self) -> bool { self.read_count > 0 } @@ -211,7 +255,7 @@ impl PipeBuffer { /// Returns true if both endpoints are closed and the pipe can be freed. pub fn is_fully_closed(&self) -> bool { - self.read_count == 0 && self.write_count == 0 + self.read_count == 0 && self.write_count == 0 && !self.orphaned_read } /// Push ancillary FDs (SCM_RIGHTS) to be delivered with the next recvmsg. @@ -317,6 +361,16 @@ impl PipeTable { } } + /// Release both endpoints of a newly allocated buffer that was never + /// published to a socket or host bridge, then make its slot reusable. + pub fn discard_unclaimed(&mut self, idx: usize) { + if let Some(pipe) = self.get_mut(idx) { + pipe.close_read_end(); + pipe.close_write_end(); + } + self.free_if_closed(idx); + } + /// Total number of slots (including freed). pub fn len(&self) -> usize { self.pipes.len() @@ -499,6 +553,43 @@ mod tests { assert!(pipe.is_fully_closed()); } + #[test] + fn test_orderly_read_close_discards_until_last_writer_closes() { + let mut pipe = PipeBuffer::new(8); + + pipe.close_read_end_orderly(); + assert!(pipe.is_read_end_open()); + assert!(!pipe.has_readers()); + assert_eq!(pipe.write(b"first"), 5); + assert_eq!(pipe.write(b"larger than capacity"), 20); + assert_eq!(pipe.available(), 0); + assert!(!pipe.is_fully_closed()); + + pipe.close_write_end(); + assert!(!pipe.is_read_end_open()); + assert!(pipe.is_fully_closed()); + } + + #[test] + fn test_orderly_read_close_preserves_other_real_readers() { + let mut pipe = PipeBuffer::new(8); + pipe.add_reader(); + + pipe.close_read_end_orderly(); + assert!(pipe.has_readers()); + assert_eq!(pipe.write(b"live"), 4); + let mut buf = [0u8; 4]; + assert_eq!(pipe.read(&mut buf), 4); + assert_eq!(&buf, b"live"); + + pipe.close_read_end_orderly(); + assert!(!pipe.has_readers()); + assert_eq!(pipe.write(b"discarded"), 9); + assert_eq!(pipe.available(), 0); + pipe.close_write_end(); + assert!(pipe.is_fully_closed()); + } + #[test] fn test_pipe_table_alloc_and_free() { let mut table = PipeTable::new(); @@ -517,4 +608,15 @@ mod tests { let idx3 = table.alloc(PipeBuffer::new(64)); assert_eq!(idx3, 0); } + + #[test] + fn test_pipe_table_discards_unclaimed_slot() { + let mut table = PipeTable::new(); + let idx = table.alloc(PipeBuffer::new(64)); + + table.discard_unclaimed(idx); + + assert_eq!(table.count_active(), 0); + assert_eq!(table.alloc(PipeBuffer::new(64)), idx); + } } diff --git a/crates/kernel/src/process.rs b/crates/kernel/src/process.rs index 5be7327463..a8ebf122f1 100644 --- a/crates/kernel/src/process.rs +++ b/crates/kernel/src/process.rs @@ -1,7 +1,7 @@ extern crate alloc; use alloc::vec::Vec; -use wasm_posix_shared::{Errno, WasmStat, WasmStatfs}; +use wasm_posix_shared::{Errno, KernelRusage, WasmStat, WasmStatfs}; use crate::fd::FdTable; use crate::lock::LockTable; @@ -34,6 +34,12 @@ pub trait HostIO { fn host_statfs(&mut self, _path: &[u8]) -> Result { Err(Errno::ENOSYS) } + fn host_pathconf(&mut self, _path: &[u8], _name: i32) -> Result, Errno> { + Err(Errno::ENOSYS) + } + fn host_fpathconf(&mut self, _handle: i64, _name: i32) -> Result, Errno> { + Err(Errno::ENOSYS) + } fn host_mkdir(&mut self, path: &[u8], mode: u32) -> Result<(), Errno>; fn host_rmdir(&mut self, path: &[u8]) -> Result<(), Errno>; fn host_unlink(&mut self, path: &[u8]) -> Result<(), Errno>; @@ -43,6 +49,9 @@ pub trait HostIO { fn host_readlink(&mut self, path: &[u8], buf: &mut [u8]) -> Result; fn host_chmod(&mut self, path: &[u8], mode: u32) -> Result<(), Errno>; fn host_chown(&mut self, path: &[u8], uid: u32, gid: u32) -> Result<(), Errno>; + fn host_lchown(&mut self, _path: &[u8], _uid: u32, _gid: u32) -> Result<(), Errno> { + Err(Errno::ENOSYS) + } fn host_access(&mut self, path: &[u8], amode: u32) -> Result<(), Errno>; fn host_opendir(&mut self, path: &[u8]) -> Result; fn host_readdir( @@ -342,12 +351,38 @@ pub trait HostIO { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ProcessState { Running, + /// Execution is suspended by a default job-control stop action. The + /// process remains alive, owns all of its resources, and stays visible in + /// procfs until SIGCONT resumes it or a terminating signal exits it. + Stopped, Exited, /// Reaped process-group leader retained only as a pgid/session identity /// placeholder while live or zombie members remain in the group. Limbo, } +/// The latest parent-observable child status record. +/// +/// POSIX gives each process at most one status-information record: generating +/// a new status replaces an older unconsumed record. The record stays on the +/// child whose state changed, where WNOWAIT can repeatedly peek it and an +/// ordinary matching wait can consume it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ChildWaitEvent { + /// One of `wait::EVENT_{EXITED,STOPPED,CONTINUED}`. + pub event_mask: u32, + /// Traditional waitpid/wait4 status encoding. + pub wait_status: i32, + /// `CLD_*` code for waitid's siginfo_t. + pub si_code: i32, + /// Exit code or signal number for waitid's si_status. + pub si_status: i32, + /// Real uid of the child when the event occurred. + pub child_uid: u32, + /// Architecture-neutral kernel/host resource-usage wire snapshot. + pub rusage: KernelRusage, +} + /// Per-process binding tracking the live mmap of `/dev/fb0`. /// /// The pixel buffer lives inside the process's wasm `Memory`. The host @@ -458,6 +493,12 @@ pub struct PosixTimerState { pub clock_id: u32, pub sigev_signo: u32, pub sigev_value: i32, + /// Kernel-facing notification mode (`SIGEV_SIGNAL`, `SIGEV_NONE`, or + /// Linux's `SIGEV_THREAD_ID`). musl implements POSIX `SIGEV_THREAD` by + /// creating a helper pthread and asking the kernel to target that TID. + pub sigev_notify: u32, + /// Target thread for `SIGEV_THREAD_ID`; zero for process-wide modes. + pub sigev_tid: u32, /// Interval for repeating timers (0 = one-shot). pub interval_sec: i64, pub interval_nsec: i64, @@ -465,8 +506,43 @@ pub struct PosixTimerState { /// 0/0 = disarmed. pub value_sec: i64, pub value_nsec: i64, - /// Number of overruns (expirations not yet handled). - pub overrun: i32, + /// True while this timer owns a queued notification not yet accepted. + pub notification_pending: bool, + /// Expirations accumulated while the current notification is pending. + pub overrun_current: i32, + /// Overrun count associated with the most recently accepted notification. + pub overrun_last: i32, +} + +/// Normalize the guest sigevent notification into the signal number passed to +/// the host timer. SIGEV_NONE uses zero internally; SIGEV_SIGNAL must name a +/// real signal so it cannot silently become a no-notification timer. +const SIGEV_SIGNAL: u32 = 0; +const SIGEV_NONE: u32 = 1; +const SIGEV_THREAD_ID: u32 = 4; + +pub(crate) fn normalize_posix_timer_signo( + sigev_notify: u32, + sigev_signo: u32, +) -> Result { + match sigev_notify { + SIGEV_NONE => Ok(0), + SIGEV_SIGNAL if (1..=64).contains(&sigev_signo) => Ok(sigev_signo), + SIGEV_THREAD_ID if (1..=64).contains(&sigev_signo) => Ok(sigev_signo), + _ => Err(Errno::EINVAL), + } +} + +#[cfg(test)] +#[test] +fn posix_timer_notification_validates_and_normalizes_signals() { + assert_eq!(normalize_posix_timer_signo(SIGEV_NONE, 14).unwrap(), 0); + assert_eq!(normalize_posix_timer_signo(SIGEV_SIGNAL, 1).unwrap(), 1); + assert_eq!(normalize_posix_timer_signo(SIGEV_SIGNAL, 64).unwrap(), 64); + assert!(normalize_posix_timer_signo(SIGEV_SIGNAL, 0).is_err()); + assert!(normalize_posix_timer_signo(SIGEV_SIGNAL, 65).is_err()); + assert_eq!(normalize_posix_timer_signo(SIGEV_THREAD_ID, 14).unwrap(), 14); + assert!(normalize_posix_timer_signo(SIGEV_THREAD_ID, 0).is_err()); } /// Per-signalfd state: the set of signals to watch. @@ -511,7 +587,13 @@ pub struct Process { /// POSIX uses this flag (not `sid == pid`) to gate setpgid EPERM checks. pub is_session_leader: bool, pub state: ProcessState, + /// Low 8-bit status supplied to `_exit()`/`exit_group()` for a normal + /// exit. Signal termination is recorded separately in `exit_signal` so + /// normal statuses 128..=255 remain distinguishable to waiters. pub exit_status: i32, + pub exit_signal: u32, + /// Latest consumable parent wait status, if any. + pub wait_event: Option, pub fd_table: FdTable, pub ofd_table: OfdTable, pub lock_table: LockTable, @@ -519,7 +601,13 @@ pub struct Process { pub sockets: SocketTable, pub cwd: Vec, pub dir_streams: Vec>, + /// Process-directed pending signals and process-wide dispositions. The + /// blocked mask remains the main thread's mask for historical ABI reasons. pub signals: SignalState, + /// Signals directed to the main thread. Its blocked mask and sigsuspend + /// save slot remain in the historical Process fields; this state owns the + /// directed pending bits and siginfo queue only. + pub main_thread_signals: PerThreadSignalState, pub memory: MemoryManager, pub terminal: TerminalState, pub environ: Vec>, @@ -548,14 +636,8 @@ pub struct Process { pub threads: Vec, /// Next thread ID to allocate. pub next_tid: u32, - /// Eventfd instances owned by this process. - pub eventfds: Vec>, /// Epoll instances owned by this process. pub epolls: Vec>, - /// Timerfd instances owned by this process. - pub timerfds: Vec>, - /// Signalfd instances owned by this process. - pub signalfds: Vec>, /// POSIX timers (timer_create / timer_settime). pub posix_timers: Vec>, /// Alternate signal stack (sigaltstack): ss_sp, ss_flags, ss_size. @@ -570,10 +652,6 @@ pub struct Process { /// from this list to return the correct FDs when the child re-runs /// code before fork(). Empty in non-fork-child processes. pub fork_pipe_replay: Vec<(i32, i32)>, - /// In-memory file buffers for memfd_create fds. - pub memfds: Vec>>, - /// Content buffers for open procfs files (snapshot at open time). - pub procfs_bufs: Vec>>, /// True if this process has called exec (for POSIX setpgid EACCES check). pub has_exec: bool, /// Live mmap of `/dev/fb0`, if any. `Some` between successful @@ -647,6 +725,9 @@ impl StdioConfig { } } +pub(crate) const PROCESS_METADATA_ARGV: u32 = 0; +pub(crate) const PROCESS_METADATA_ENVIRONMENT: u32 = 1; + impl Process { /// Create a new process with captured, pipe-backed stdio. pub fn new(pid: u32) -> Self { @@ -700,6 +781,8 @@ impl Process { is_session_leader: false, state: ProcessState::Running, exit_status: 0, + exit_signal: 0, + wait_event: None, fd_table, ofd_table, lock_table: LockTable::new(), @@ -708,6 +791,7 @@ impl Process { cwd: alloc::vec![b'/'], dir_streams: Vec::new(), signals: SignalState::new(), + main_thread_signals: PerThreadSignalState::new(), memory: MemoryManager::new(), terminal: TerminalState::new(), environ: Vec::new(), @@ -726,18 +810,13 @@ impl Process { next_ephemeral_port: 49152, threads: Vec::new(), next_tid: 0, // will be set to pid + 1 after pid is known - eventfds: Vec::new(), epolls: Vec::new(), - timerfds: Vec::new(), - signalfds: Vec::new(), posix_timers: Vec::new(), alt_stack_sp: 0, alt_stack_flags: 2, // SS_DISABLE alt_stack_size: 0, alt_stack_depth: 0, fork_pipe_replay: Vec::new(), - memfds: Vec::new(), - procfs_bufs: Vec::new(), has_exec: false, fb_binding: None, dri_bindings: Vec::new(), @@ -756,6 +835,136 @@ impl Process { self.fork_count += 1; } + fn set_wait_event( + &mut self, + event_mask: u32, + wait_status: i32, + si_code: i32, + si_status: i32, + ) { + self.wait_event = Some(ChildWaitEvent { + event_mask, + wait_status, + si_code, + si_status, + child_uid: self.uid, + rusage: KernelRusage::default(), + }); + } + + /// Apply a delivered default stop action. Repeated stop signals while the + /// process is already stopped do not create duplicate state transitions. + pub fn record_stop(&mut self, signum: u32) -> bool { + if self.state != ProcessState::Running { + return false; + } + self.state = ProcessState::Stopped; + self.set_wait_event( + wasm_posix_shared::wait::EVENT_STOPPED, + ((signum as i32) << 8) | 0x7f, + wasm_posix_shared::wait::CLD_STOPPED, + signum as i32, + ); + crate::wakeup::push( + self.pid, + wasm_posix_shared::wait::WAKE_PROCESS_STOPPED as u8, + ); + true + } + + /// Resume a stopped process. SIGCONT invokes this at signal-generation + /// time, before its blocked mask or disposition is consulted. + pub fn record_continue(&mut self) -> bool { + if self.state != ProcessState::Stopped { + return false; + } + self.state = ProcessState::Running; + self.set_wait_event( + wasm_posix_shared::wait::EVENT_CONTINUED, + 0xffff, + wasm_posix_shared::wait::CLD_CONTINUED, + wasm_posix_shared::signal::SIGCONT as i32, + ); + crate::wakeup::push( + self.pid, + wasm_posix_shared::wait::WAKE_PROCESS_CONTINUED as u8, + ); + true + } + + /// Record one normal process exit after exit cleanup has completed. + pub fn record_normal_exit(&mut self, status: i32) -> bool { + if self.state == ProcessState::Exited || self.state == ProcessState::Limbo { + return false; + } + let status = status & 0xff; + self.state = ProcessState::Exited; + self.exit_status = status; + self.exit_signal = 0; + self.set_wait_event( + wasm_posix_shared::wait::EVENT_EXITED, + status << 8, + wasm_posix_shared::wait::CLD_EXITED, + status, + ); + true + } + + /// Record one signal-caused process exit after exit cleanup has completed. + pub fn record_signal_exit(&mut self, signum: u32) -> bool { + if self.state == ProcessState::Exited || self.state == ProcessState::Limbo { + return false; + } + let signum = signum & 0x7f; + self.state = ProcessState::Exited; + self.exit_status = 0; + self.exit_signal = signum; + self.set_wait_event( + wasm_posix_shared::wait::EVENT_EXITED, + signum as i32, + wasm_posix_shared::wait::CLD_KILLED, + signum as i32, + ); + true + } + + pub(crate) fn clear_signal_everywhere(&mut self, signum: u32) { + self.signals.clear_pending(signum); + self.clear_directed_signal(signum); + } + + /// Apply process-control effects when a signal is generated, before the + /// signal is queued or tested against a mask/disposition. + fn prepare_signal_generation(&mut self, signum: u32) { + use wasm_posix_shared::signal::{ + NSIG, SIGCONT, SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU, + }; + + if signum == 0 || signum >= NSIG { + return; + } + if signum == SIGCONT { + for stop in [SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU] { + self.clear_signal_everywhere(stop); + } + // This transition is mandatory even when SIGCONT is blocked, + // ignored, or caught. The signal itself is still queued below. + self.record_continue(); + } else if matches!(signum, SIGSTOP | SIGTSTP | SIGTTIN | SIGTTOU) { + self.clear_signal_everywhere(SIGCONT); + } + } + + pub fn raise_signal(&mut self, signum: u32) -> bool { + self.prepare_signal_generation(signum); + self.signals.raise(signum) + } + + pub fn raise_signal_with_value(&mut self, signum: u32, si_value: i32) -> bool { + self.prepare_signal_generation(signum); + self.signals.raise_with_value(signum, si_value) + } + /// Compatibility helper for the legacy pipe slot vector, reusing the first /// free slot. Runtime pipe operations use the kernel-global pipe table. pub fn alloc_pipe(&mut self, pipe: PipeBuffer) -> usize { @@ -824,8 +1033,8 @@ impl Process { /// True if `tid` names the process's main thread. The main thread's TID /// equals the process PID (Linux convention) and is not tracked in - /// [`Process::threads`]; per-thread signal state for the main thread lives - /// in [`Process::signals`] instead. + /// [`Process::threads`]; its blocked mask lives in [`Process::signals`] + /// and its directed pending queue in [`Process::main_thread_signals`]. /// /// `tid == 0` is also treated as "main thread" because the host uses 0 /// for syscalls from the main channel (no thread worker is involved). @@ -833,6 +1042,13 @@ impl Process { tid == 0 || tid == self.pid } + /// True when a nonzero TID explicitly names the live process leader or a + /// retained worker. Kernel-internal TID 0 aliases the leader but is not a + /// valid user-supplied exact-thread target. + pub fn is_live_explicit_tid(&self, tid: u32) -> bool { + tid != 0 && (tid == self.pid || self.get_thread(tid).is_some()) + } + /// Effective blocked mask for the given TID. pub fn blocked_for(&self, tid: u32) -> u64 { if self.is_main_thread(tid) { @@ -867,7 +1083,7 @@ impl Process { /// unblocked. pub fn pending_for(&self, tid: u32) -> u64 { if self.is_main_thread(tid) { - self.signals.pending + self.signals.pending | self.main_thread_signals.pending } else { let thread_pending = self.get_thread(tid).map(|t| t.signals.pending).unwrap_or(0); self.signals.pending | thread_pending @@ -883,7 +1099,7 @@ impl Process { let bit = crate::signal::sig_bit(sig); let shared = (self.signals.pending & bit) != 0; if self.is_main_thread(tid) { - shared + shared || (self.main_thread_signals.pending & bit) != 0 } else { let thread_bit = self .get_thread(tid) @@ -893,6 +1109,22 @@ impl Process { } } + /// Whether a pending instance exists in the shared queue or any directed + /// thread queue. SIGKILL uses this while stopped because it terminates the + /// whole process regardless of which thread was targeted. + pub fn signal_pending_anywhere(&self, sig: u32) -> bool { + if sig == 0 || sig >= wasm_posix_shared::signal::NSIG { + return false; + } + let bit = crate::signal::sig_bit(sig); + self.signals.pending & bit != 0 + || self.main_thread_signals.pending & bit != 0 + || self + .threads + .iter() + .any(|thread| thread.signals.pending & bit != 0) + } + /// Pick a thread TID that does not block `sig`. Preference order: /// 1. Main thread, if it does not block `sig`. /// 2. Any worker thread (in allocation order) with `sig` unblocked. @@ -922,6 +1154,222 @@ impl Process { pending & !blocked } + /// Return the next signal deliverable in the current lifecycle state. + /// Stopped processes retain every pending signal except SIGKILL; SIGCONT + /// resumes at generation time and reaches this method as Running. + pub fn next_deliverable_signal(&self, tid: u32) -> Option { + if self.state == ProcessState::Stopped { + let sigkill = wasm_posix_shared::signal::SIGKILL; + return self.signal_pending_anywhere(sigkill).then_some(sigkill); + } + + let deliverable = self.deliverable_for(tid); + if deliverable == 0 { + return None; + } + let signum = deliverable.trailing_zeros() + 1; + (signum < wasm_posix_shared::signal::NSIG).then_some(signum) + } + + /// Whether the lowest-numbered signal deliverable to `tid` carries + /// SA_RESTART. Dispositions are process-wide even for directed signals. + pub fn should_restart_for(&self, tid: u32) -> bool { + let deliverable = self.deliverable_for(tid); + if deliverable == 0 { + return false; + } + let signum = deliverable.trailing_zeros() + 1; + if signum >= wasm_posix_shared::signal::NSIG { + return false; + } + self.signals.get_action(signum).flags & wasm_posix_shared::signal::SA_RESTART != 0 + } + + /// Queue a signal for one exact thread. Main-thread-directed signals have + /// their own queue because `SignalState::pending` is process-shared. + pub fn raise_for_thread(&mut self, tid: u32, signum: u32) -> bool { + self.prepare_signal_generation(signum); + if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { + return false; + } + let handler = self.signals.get_handler(signum); + if crate::signal::should_discard_pending(signum, &handler) { + return true; + } + if self.is_main_thread(tid) { + self.main_thread_signals.raise(signum) + } else if let Some(thread) = self.get_thread_mut(tid) { + thread.signals.raise(signum) + } else { + false + } + } + + /// Queue a signal with sigqueue metadata for one exact thread. + pub fn raise_for_thread_with_value( + &mut self, + tid: u32, + signum: u32, + si_value: i32, + ) -> bool { + self.prepare_signal_generation(signum); + if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { + return false; + } + let handler = self.signals.get_handler(signum); + if crate::signal::should_discard_pending(signum, &handler) { + return true; + } + if self.is_main_thread(tid) { + self.main_thread_signals + .raise_with_value(signum, si_value) + } else if let Some(thread) = self.get_thread_mut(tid) { + thread.signals.raise_with_value(signum, si_value) + } else { + false + } + } + + /// Queue one timer notification for an exact live thread, including the + /// main thread's dedicated directed queue. + pub fn raise_timer_for_thread( + &mut self, + tid: u32, + signum: u32, + si_value: i32, + timer_id: u32, + ) -> bool { + self.prepare_signal_generation(signum); + if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { + return false; + } + let handler = self.signals.get_handler(signum); + if crate::signal::should_discard_pending(signum, &handler) { + return true; + } + if self.is_main_thread(tid) { + self.main_thread_signals + .raise_timer(signum, si_value, timer_id) + } else if let Some(thread) = self.get_thread_mut(tid) { + thread.signals.raise_timer(signum, si_value, timer_id) + } else { + false + } + } + + /// Clear a directed signal from every thread. Used when a new + /// disposition requires pending instances to be discarded. + pub fn clear_directed_signal(&mut self, signum: u32) { + self.main_thread_signals.clear_pending(signum); + for thread in &mut self.threads { + thread.signals.clear_pending(signum); + } + } + + /// Consume one pending instance visible to `tid`, preferring that exact + /// thread's directed queue before the shared process queue. + pub fn consume_signal_for( + &mut self, + tid: u32, + signum: u32, + ) -> Option { + let directed = if self.is_main_thread(tid) { + self.main_thread_signals + .is_pending(signum) + .then(|| self.main_thread_signals.consume_one_info(signum)) + } else if let Some(thread) = self.get_thread_mut(tid) { + thread + .signals + .is_pending(signum) + .then(|| thread.signals.consume_one_info(signum)) + } else { + None + }; + if directed.is_some() { + return directed; + } + if self.signals.pending & crate::signal::sig_bit(signum) == 0 { + return None; + } + Some(self.signals.consume_one(signum)) + } + + /// Mark a timer notification as accepted and snapshot its overrun count. + pub fn accept_posix_timer_notification(&mut self, timer_id: u32) -> Option { + let timer = self.posix_timers.get_mut(timer_id as usize)?.as_mut()?; + if !timer.notification_pending { + return None; + } + timer.notification_pending = false; + timer.overrun_last = timer.overrun_current; + timer.overrun_current = 0; + Some(timer.overrun_last) + } + + /// Preserve the interval-fire contract used by hosts predating the + /// kernel-owned POSIX timer notification path. Returns true when the host + /// must suppress a duplicate process-wide signal. + pub fn note_legacy_posix_timer_interval_fire(&mut self, timer_id: u32) -> bool { + let signum = match self.posix_timers.get(timer_id as usize) { + Some(Some(timer)) => timer.sigev_signo, + _ => return false, + }; + if signum == 0 || signum >= wasm_posix_shared::signal::NSIG { + return false; + } + + let pending = (self.signals.pending & crate::signal::sig_bit(signum)) != 0; + let timer = self.posix_timers[timer_id as usize].as_mut().unwrap(); + if pending { + timer.overrun_last = timer.overrun_last.saturating_add(1); + } else { + timer.overrun_last = 0; + } + pending + } + + /// Discard every shared and directed pending instance of a signal. + pub fn discard_pending_signal(&mut self, signum: u32) { + let mut timer_ids: Vec = self.signals.pending_timer_ids(signum).collect(); + timer_ids.extend( + self.main_thread_signals + .rt_queue + .iter() + .filter(|entry| entry.signum == signum) + .filter_map(|entry| entry.timer_id), + ); + for thread in &self.threads { + timer_ids.extend( + thread + .signals + .rt_queue + .iter() + .filter(|entry| entry.signum == signum) + .filter_map(|entry| entry.timer_id), + ); + } + self.signals.clear_pending(signum); + self.main_thread_signals.clear_pending(signum); + for thread in &mut self.threads { + thread.signals.clear_pending(signum); + } + for timer_id in timer_ids { + self.accept_posix_timer_notification(timer_id); + } + } + + /// Purge a deleted timer's queued notification before its slot is reused. + pub fn remove_posix_timer_notification(&mut self, timer_id: u32) -> bool { + let mut removed = self.signals.remove_timer_notification(timer_id); + removed |= self + .main_thread_signals + .remove_timer_notification(timer_id); + for thread in &mut self.threads { + removed |= thread.signals.remove_timer_notification(timer_id); + } + removed + } + /// Read the saved sigsuspend/ppoll/pselect mask for TID. pub fn sigsuspend_saved_mask_for(&self, tid: u32) -> Option { if self.is_main_thread(tid) { @@ -970,6 +1418,32 @@ impl Process { } out } + + fn metadata_vector_mut(&mut self, kind: u32) -> Result<&mut Vec>, Errno> { + match kind { + PROCESS_METADATA_ARGV => Ok(&mut self.argv), + PROCESS_METADATA_ENVIRONMENT => Ok(&mut self.environ), + _ => Err(Errno::EINVAL), + } + } + + pub(crate) fn clear_metadata(&mut self, kind: u32) -> Result<(), Errno> { + self.metadata_vector_mut(kind)?.clear(); + Ok(()) + } + + pub(crate) fn push_metadata_entry(&mut self, kind: u32, entry: &[u8]) -> Result<(), Errno> { + let mut owned = Vec::new(); + owned + .try_reserve_exact(entry.len()) + .map_err(|_| Errno::ENOMEM)?; + owned.extend_from_slice(entry); + + let entries = self.metadata_vector_mut(kind)?; + entries.try_reserve(1).map_err(|_| Errno::ENOMEM)?; + entries.push(owned); + Ok(()) + } } /// A `HostIO` impl that returns sensible defaults for the methods our @@ -1199,6 +1673,234 @@ mod tests { assert_eq!(proc.fork_count(), 0); } + #[test] + fn child_status_record_is_replaced_by_each_new_transition() { + use wasm_posix_shared::signal::{SIGCONT, SIGTERM, SIGTSTP}; + use wasm_posix_shared::wait::{ + CLD_CONTINUED, CLD_KILLED, CLD_STOPPED, EVENT_CONTINUED, EVENT_EXITED, + EVENT_STOPPED, + }; + + let mut proc = Process::new(41); + assert!(proc.record_stop(SIGTSTP)); + let stopped = proc.wait_event.unwrap(); + assert_eq!(stopped.event_mask, EVENT_STOPPED); + assert_eq!(stopped.si_code, CLD_STOPPED); + assert_eq!(stopped.si_status, SIGTSTP as i32); + + assert!(proc.record_continue()); + let continued = proc.wait_event.unwrap(); + assert_eq!(continued.event_mask, EVENT_CONTINUED); + assert_eq!(continued.wait_status, 0xffff); + assert_eq!(continued.si_code, CLD_CONTINUED); + assert_eq!(continued.si_status, SIGCONT as i32); + + assert!(proc.record_signal_exit(SIGTERM)); + let exited = proc.wait_event.unwrap(); + assert_eq!(exited.event_mask, EVENT_EXITED); + assert_eq!(exited.wait_status, SIGTERM as i32); + assert_eq!(exited.si_code, CLD_KILLED); + assert_eq!(exited.si_status, SIGTERM as i32); + } + + #[test] + fn sigcont_resumes_immediately_for_blocked_caught_and_ignored_dispositions() { + use crate::signal::{SignalHandler, sig_bit}; + use wasm_posix_shared::signal::{SIGCONT, SIGSTOP}; + use wasm_posix_shared::wait::EVENT_CONTINUED; + + for (handler, blocked, expect_pending) in [ + (SignalHandler::Default, true, true), + (SignalHandler::Handler(7), false, true), + (SignalHandler::Ignore, false, false), + ] { + let mut proc = Process::new(42); + proc.signals.set_handler(SIGCONT, handler).unwrap(); + if blocked { + proc.signals.blocked |= sig_bit(SIGCONT); + } + assert!(proc.record_stop(SIGSTOP)); + + let queued = proc.raise_signal(SIGCONT); + + assert_eq!(proc.state, ProcessState::Running); + assert_eq!(proc.wait_event.unwrap().event_mask, EVENT_CONTINUED); + if expect_pending { + assert!(queued); + assert!(proc.signals.is_pending(SIGCONT)); + } else { + assert!(!proc.signals.is_pending(SIGCONT)); + } + } + } + + #[test] + fn job_control_generation_cancels_opposing_pending_signals_everywhere() { + use crate::signal::sig_bit; + use wasm_posix_shared::signal::{SIGCONT, SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU}; + + let mut proc = Process::new(43); + proc.add_thread(ThreadInfo::new(99, 0, 0, 0)); + proc.signals.raise(SIGSTOP); + proc.main_thread_signals.raise(SIGTSTP); + proc.threads[0].signals.raise(SIGTTIN); + proc.threads[0].signals.raise(SIGTTOU); + + assert!(proc.raise_signal(SIGCONT)); + let stop_bits = [SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU] + .into_iter() + .fold(0, |bits, sig| bits | sig_bit(sig)); + assert_eq!(proc.signals.pending & stop_bits, 0); + assert_eq!(proc.main_thread_signals.pending & stop_bits, 0); + assert_eq!(proc.threads[0].signals.pending & stop_bits, 0); + + proc.main_thread_signals.raise(SIGCONT); + proc.threads[0].signals.raise(SIGCONT); + assert!(proc.raise_signal(SIGSTOP)); + assert!(!proc.signals.is_pending(SIGCONT)); + assert_eq!(proc.main_thread_signals.pending & sig_bit(SIGCONT), 0); + assert_eq!(proc.threads[0].signals.pending & sig_bit(SIGCONT), 0); + } + + #[test] + fn metadata_entry_transport_preserves_empty_values_and_empty_environment() { + let mut proc = Process::new(77); + proc.argv = vec![b"old".to_vec()]; + proc.environ = vec![b"OLD=value".to_vec()]; + + proc.clear_metadata(PROCESS_METADATA_ARGV).unwrap(); + proc.push_metadata_entry(PROCESS_METADATA_ARGV, b"new") + .unwrap(); + proc.push_metadata_entry(PROCESS_METADATA_ARGV, b"") + .unwrap(); + proc.clear_metadata(PROCESS_METADATA_ENVIRONMENT).unwrap(); + + assert_eq!(proc.argv, vec![b"new".to_vec(), Vec::new()]); + assert!(proc.environ.is_empty()); + } + + #[test] + fn metadata_entry_transport_rejects_unknown_vector_kind() { + let mut proc = Process::new(78); + assert_eq!(proc.clear_metadata(99), Err(Errno::EINVAL)); + assert_eq!(proc.push_metadata_entry(99, b"value"), Err(Errno::EINVAL)); + } + + #[test] + fn accepting_timer_notification_snapshots_overrun() { + let mut proc = Process::new(1); + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 1, + sigev_signo: 32, + sigev_value: 7, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 3, + overrun_last: 1, + })); + + assert_eq!(proc.accept_posix_timer_notification(0), Some(3)); + let timer = proc.posix_timers[0].as_ref().unwrap(); + assert!(!timer.notification_pending); + assert_eq!(timer.overrun_current, 0); + assert_eq!(timer.overrun_last, 3); + assert_eq!(proc.accept_posix_timer_notification(0), None); + } + + #[test] + fn exact_thread_targets_accept_leader_and_live_worker_only() { + let mut proc = Process::new(41); + proc.add_thread(ThreadInfo::new(42, 0, 0, 0)); + + assert!(!proc.is_live_explicit_tid(0)); + assert!(proc.is_live_explicit_tid(41)); + assert!(proc.is_live_explicit_tid(42)); + assert!(!proc.is_live_explicit_tid(43)); + proc.remove_thread(42); + assert!(!proc.is_live_explicit_tid(42)); + } + + #[test] + fn legacy_interval_fire_preserves_host_signal_contract() { + let mut proc = Process::new(1); + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value: 7, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: false, + overrun_current: 0, + overrun_last: 4, + })); + + assert!(!proc.note_legacy_posix_timer_interval_fire(0)); + assert_eq!(proc.posix_timers[0].as_ref().unwrap().overrun_last, 0); + + proc.signals.raise(10); + assert!(proc.note_legacy_posix_timer_interval_fire(0)); + assert_eq!(proc.posix_timers[0].as_ref().unwrap().overrun_last, 1); + assert!(proc.note_legacy_posix_timer_interval_fire(0)); + assert_eq!(proc.posix_timers[0].as_ref().unwrap().overrun_last, 2); + } + + #[test] + fn deleting_timer_notification_prevents_slot_reuse_aba() { + let mut proc = Process::new(1); + proc.add_thread(ThreadInfo::new(2, 0, 0, 0)); + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value: 7, + sigev_notify: 4, + sigev_tid: 2, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 2, + overrun_last: 0, + })); + proc.get_thread_mut(2).unwrap().signals.raise_timer(10, 7, 0); + + assert!(proc.remove_posix_timer_notification(0)); + proc.posix_timers[0] = Some(PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value: 8, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: false, + overrun_current: 0, + overrun_last: 0, + }); + + assert!(!proc.get_thread(2).unwrap().signals.is_pending(10)); + assert_eq!( + proc.get_thread_mut(2) + .unwrap() + .signals + .consume_one_info(10) + .timer_id, + None, + ); + assert_eq!(proc.accept_posix_timer_notification(0), None); + } + #[test] fn new_creates_captured_stdio_as_pipes() { let proc = Process::new(1); @@ -1245,6 +1947,7 @@ mod tests { let child = table.get(child_pid).expect("child in table"); assert_eq!(child.cwd, b"/tmp", "child inherits parent cwd"); assert_eq!(child.ppid, 100, "child ppid is parent pid"); + assert!(child.wait_event.is_none(), "spawn child starts without status"); assert_eq!( child.argv, alloc::vec![b"/bin/echo".to_vec(), b"hi".to_vec()], @@ -1381,7 +2084,16 @@ mod tests { udp.dgram_queue.push(Datagram { data: b"hello".to_vec(), src_addr: [127, 0, 0, 1], + src_addr6: [0; 16], + dst_addr: [127, 0, 0, 1], + dst_addr6: [0; 16], src_port: 12345, + src_sock_idx: None, + ipv6_tclass: 0, + src_pid: 400, + src_uid: 0, + src_gid: 0, + ancillary_fds: Vec::new(), }); let mut tcp = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); tcp.oob_byte = Some(0xAB); @@ -1580,6 +2292,82 @@ mod tests { assert_eq!(host_net_handle_ref_count(HANDLE), 0); } + #[test] + fn remove_process_emits_host_file_close_only_on_last_ref() { + // Forced host teardown removes a process without running sys_exit. + // Its live host-backed OFDs must still drop their inherited ownership, + // and only the last owner may close the shared backend handle. + use crate::fd::FdTable; + use crate::ofd::{FileType, OfdTable, host_handle_ref_count}; + use crate::process_table::ProcessTable; + + const HANDLE: i64 = 900_000_091; + let mut table = ProcessTable::new(); + table.create_process(610).unwrap(); + let parent = table.processes.get_mut(&610).unwrap(); + // Keep the assertion independent of globally-numbered stdio handles, + // which other ProcessTable tests may share while the test runner is + // executing in parallel. + parent.fd_table = FdTable::new(); + parent.ofd_table = OfdTable::new(); + let ofd_idx = parent.ofd_table.create( + FileType::Regular, + wasm_posix_shared::flags::O_RDONLY, + HANDLE, + b"/tmp/forced-exit-file".to_vec(), + ); + parent + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + table.fork_process(610, 611).expect("fork_process"); + assert_eq!(host_handle_ref_count(HANDLE), 2); + + let child = table.remove_process(611).expect("remove child"); + assert!(child.host_closes.is_empty()); + assert_eq!(host_handle_ref_count(HANDLE), 1); + + let parent = table.remove_process(610).expect("remove parent"); + assert_eq!(parent.host_closes, alloc::vec![HANDLE]); + assert_eq!(host_handle_ref_count(HANDLE), 0); + } + + #[test] + fn remove_process_emits_all_uninherited_directory_handles() { + use crate::fd::FdTable; + use crate::ofd::{FileType, OfdTable}; + use crate::process::{DirStream, Process}; + use crate::process_table::ProcessTable; + + let mut table = ProcessTable::new(); + table.processes.insert(620, Process::new(620)); + let process = table.processes.get_mut(&620).unwrap(); + process.fd_table = FdTable::new(); + process.ofd_table = OfdTable::new(); + let ofd_idx = process.ofd_table.create( + FileType::Directory, + wasm_posix_shared::flags::O_RDONLY, + 92, + b"/tmp".to_vec(), + ); + process.ofd_table.get_mut(ofd_idx).unwrap().dir_host_handle = 7; + process + .fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + process.dir_streams.push(Some(DirStream { + host_handle: 8, + path: b"/var".to_vec(), + position: 0, + synth_dot_state: 0, + })); + + let removed = table.remove_process(620).expect("remove process"); + assert_eq!(removed.host_dir_closes, alloc::vec![7, 8]); + assert_eq!(removed.host_closes, alloc::vec![92]); + } + #[test] fn spawn_child_applies_close_action() { // Parent has fd 5 → some inherited OFD. After spawn with file diff --git a/crates/kernel/src/process_table.rs b/crates/kernel/src/process_table.rs index 6632161df0..80bfae6cd0 100644 --- a/crates/kernel/src/process_table.rs +++ b/crates/kernel/src/process_table.rs @@ -21,7 +21,7 @@ use wasm_posix_shared::flags::O_ACCMODE; use wasm_posix_shared::Errno; use crate::ofd::FileType; -use crate::process::{Process, ProcessState, StdioConfig}; +use crate::process::{ChildWaitEvent, Process, ProcessState, StdioConfig}; const INITIAL_FORK_STATE_BUFFER_LEN: usize = 64 * 1024; const MAX_FORK_STATE_BUFFER_LEN: usize = 4 * 1024 * 1024; @@ -58,12 +58,19 @@ pub struct ProcessTable { } /// Outcome of `ProcessTable::remove_process`. Bundles the removed -/// `Process` with side-effect lists the caller must drain — currently -/// just AF_INET host net handles whose cross-process refcount hit zero -/// during cleanup. The caller is `kernel_remove_process`, which has -/// access to the raw `host_net_close` extern; this layer doesn't. +/// `Process` with side-effect lists the caller must drain: file, directory, +/// and AF_INET host handles released during cleanup. The caller is +/// `kernel_remove_process`, which has access to the raw host-close externs; +/// this layer doesn't. pub struct RemoveProcessResult { pub process: Process, + /// Host file handles whose cross-process refcount reached 0 during + /// teardown. The caller must invoke `host_close(h)` on each. + pub host_closes: Vec, + /// Per-process directory-iteration handles that were still open during + /// teardown. These are never inherited across fork, so every retained + /// handle must be closed by the caller. + pub host_dir_closes: Vec, /// Host net handles whose cross-process refcount reached 0 during /// teardown. The caller must invoke `host_net_close(h)` on each — /// this kernel-side bookkeeping intentionally doesn't touch the @@ -106,8 +113,30 @@ struct SpawnInheritFromParent { /// out from under the child. /// /// The function operates only on global tables and the child's own state, -/// so it does not need access to `ProcessTable`. -fn bump_inherited_resource_refcounts(child: &Process) { +/// so it does not need access to `ProcessTable`. `parent_pid` identifies the +/// exact source owner when copying machine-wide INET binding ownership. +pub(crate) fn bump_inherited_resource_refcounts( + parent_pid: u32, + child: &Process, +) -> Result<(), Errno> { + // Backings for eventfd/timerfd/signalfd/memfd/procfs are indexed by the + // inherited OFD's stable negative handle. Add these fallible references + // first, rolling them back if a stale handle is encountered, before + // touching the older infallible global-resource refcounts below. + let mut shared_backings_bumped: Vec<(FileType, i64)> = Vec::new(); + for (_idx, ofd) in child.ofd_table.iter() { + match crate::descriptor_backing::add_ref_for_ofd(ofd.file_type, ofd.host_handle) { + Ok(true) => shared_backings_bumped.push((ofd.file_type, ofd.host_handle)), + Ok(false) => {} + Err(err) => { + for (file_type, host_handle) in shared_backings_bumped.into_iter().rev() { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); + } + return Err(err); + } + } + } + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; // Pipe-OFDs (host_handle is the negative-encoded global pipe index). @@ -177,7 +206,7 @@ fn bump_inherited_resource_refcounts(child: &Process) { } } - // Shared listener backlog (AF_INET listeners) and host_net_handle + // Shared listener backlog (AF_INET/AF_INET6 listeners) and host_net_handle // (connected AF_INET sockets): increment one ref per socket entry that // carries one. close() and process exit each drop one ref; last-drop // either frees the listener slot or calls host_net_close. Iterates @@ -187,14 +216,21 @@ fn bump_inherited_resource_refcounts(child: &Process) { let backlog_table = unsafe { crate::socket::shared_listener_backlog_table() }; for sock_idx in 0..child.sockets.len() { if let Some(sock) = child.sockets.get(sock_idx) { + crate::socket::inherit_inet_binding_owners(parent_pid, child.pid, sock_idx); if let Some(shared_idx) = sock.shared_backlog_idx { backlog_table.add_ref(shared_idx); } if let Some(net_handle) = sock.host_net_handle { crate::socket::host_net_handle_fork_ref(net_handle); } + if let Some(path) = sock.bind_path.as_deref() { + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.add_owner(path, child.pid, sock_idx); + } } } + + Ok(()) } /// Build the fork-only `fork_pipe_replay` table: a list of (read_fd, @@ -204,18 +240,16 @@ fn bump_inherited_resource_refcounts(child: &Process) { fn build_fork_pipe_replay(child: &Process) -> Vec<(i32, i32)> { use alloc::collections::BTreeMap; let mut pipe_fd_pairs: BTreeMap = BTreeMap::new(); - for fd in 0..1024i32 { - if let Ok(entry) = child.fd_table.get(fd) { - if let Some(ofd) = child.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { - let pipe_idx = (-(ofd.host_handle + 1)) as usize; - let access_mode = ofd.status_flags & O_ACCMODE; - let pair = pipe_fd_pairs.entry(pipe_idx).or_insert((-1, -1)); - if access_mode == wasm_posix_shared::flags::O_RDONLY { - pair.0 = fd; - } else { - pair.1 = fd; - } + for (fd, entry) in child.fd_table.iter() { + if let Some(ofd) = child.ofd_table.get(entry.ofd_ref.0) { + if ofd.file_type == FileType::Pipe && ofd.host_handle < 0 { + let pipe_idx = (-(ofd.host_handle + 1)) as usize; + let access_mode = ofd.status_flags & O_ACCMODE; + let pair = pipe_fd_pairs.entry(pipe_idx).or_insert((-1, -1)); + if access_mode == wasm_posix_shared::flags::O_RDONLY { + pair.0 = fd; + } else { + pair.1 = fd; } } } @@ -309,6 +343,8 @@ impl ProcessTable { retain_limbo_leader: bool, ) -> Option { let proc = self.processes.remove(&pid)?; + let mut host_closes: Vec = Vec::new(); + let mut host_dir_closes: Vec = Vec::new(); let mut host_net_closes: Vec = Vec::new(); let pipe_table = unsafe { crate::pipe::global_pipe_table() }; @@ -360,7 +396,41 @@ impl ProcessTable { } } - // Clean up socket OFDs: close pipe endpoints so peers get EOF/EPIPE. + // Drop kernel-global eventfd/timerfd/signalfd/memfd/procfs backing + // references for every OFD the process still owns. Normal exit closes + // fds first; this also covers crash removal and spawn rollback. + for (_ofd_idx, ofd) in proc.ofd_table.iter() { + crate::descriptor_backing::release_for_ofd(ofd.file_type, ofd.host_handle); + } + + // Drop host-backed file and directory handles that a process still + // owned when it was removed without reaching sys_exit (worker crash, + // explicit host termination, or failed fork/spawn launch). Normal + // exit closes every fd first, so these lists are empty on the zombie + // reaping path. Fork/spawn share positive host handles by refcount; + // only the last process queues the underlying host_close. + for (_ofd_idx, ofd) in proc.ofd_table.iter() { + if ofd.dir_host_handle >= 0 { + host_dir_closes.push(ofd.dir_host_handle); + } + if ofd.host_handle < 0 { + continue; + } + if matches!( + ofd.file_type, + FileType::Regular | FileType::Directory | FileType::CharDevice | FileType::Pipe + ) && crate::ofd::host_handle_close_ref(ofd.host_handle) + { + host_closes.push(ofd.host_handle); + } + } + for stream in proc.dir_streams.iter().flatten() { + host_dir_closes.push(stream.host_handle); + } + + // Clean up socket OFDs. Active TCP streams use the same orderly FIN + // and orphaned receive state as close(2); other socket kinds close + // their pipe endpoints directly. // Without this, a peer process reading from a connected socket would // block forever instead of getting EOF when this process exits. // @@ -374,6 +444,14 @@ impl ProcessTable { let sock_idx = (-(ofd.host_handle + 1)) as usize; if let Some(sock) = proc.sockets.get(sock_idx) { if sock.global_pipes { + let orderly_tcp_close = matches!( + (sock.domain, sock.sock_type), + ( + crate::socket::SocketDomain::Inet + | crate::socket::SocketDomain::Inet6, + crate::socket::SocketType::Stream, + ) + ); // Cross-process socket: close pipe ends in global table if let Some(send_idx) = sock.send_buf_idx { if let Some(pipe) = pipe_table.get_mut(send_idx) { @@ -383,7 +461,11 @@ impl ProcessTable { } if let Some(recv_idx) = sock.recv_buf_idx { if let Some(pipe) = pipe_table.get_mut(recv_idx) { - pipe.close_read_end(); + if orderly_tcp_close { + pipe.close_read_end_orderly(); + } else { + pipe.close_read_end(); + } } pipe_table.free_if_closed(recv_idx); } @@ -443,7 +525,9 @@ impl ProcessTable { // Clean up AF_INET bind table entries for sockets the process held. crate::socket::udp_cleanup_process(pid); + crate::socket::udp6_cleanup_process(pid); crate::socket::tcp_cleanup_process(pid); + crate::socket::tcp6_cleanup_process(pid); // Release any PTHREAD_PROCESS_SHARED primitives owned by this pid // so peers aren't wedged on mutexes or waiter queues. @@ -466,6 +550,8 @@ impl ProcessTable { Some(RemoveProcessResult { process: proc, + host_closes, + host_dir_closes, host_net_closes, }) } @@ -488,6 +574,7 @@ impl ProcessTable { limbo.is_session_leader = proc.is_session_leader; limbo.state = ProcessState::Limbo; limbo.exit_status = proc.exit_status; + limbo.exit_signal = proc.exit_signal; limbo.cwd = proc.cwd.clone(); limbo.environ = proc.environ.clone(); limbo.argv = proc.argv.clone(); @@ -564,6 +651,12 @@ impl ProcessTable { } let serialized_parent = { let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; + if matches!( + parent.state, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo + ) { + return Err(Errno::ESRCH); + } serialize_fork_state_with_growing_buffer(parent)? }; @@ -573,7 +666,7 @@ impl ProcessTable { // Bump cross-process refcounts on inherited fd state (host handles, // global pipes, PTYs, socket-pipes). Identical to spawn's needs — // factored out into a free helper. - bump_inherited_resource_refcounts(&child); + bump_inherited_resource_refcounts(parent_pid, &child)?; // Build fork-only `fork_pipe_replay` (fork replay needs it to // return the same fds as the parent did when re-running @@ -592,6 +685,83 @@ impl ProcessTable { Ok(()) } + /// Insert a process produced by the retained legacy fork-state ABI. + /// Unlike a raw map insert, this refuses to replace an existing pid and + /// either establishes same-instance inherited refs or preserves fresh- + /// kernel sole ownership before moving the process into the table. + #[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) + )] + pub(crate) fn insert_legacy_fork_process(&mut self, child: Process) -> Result<(), Errno> { + if self.processes.contains_key(&child.pid) { + return Err(Errno::EEXIST); + } + + if self.processes.contains_key(&child.ppid) { + // Same-instance legacy install: the parent still owns every + // inherited resource, so establish the child's additional refs. + bump_inherited_resource_refcounts(child.ppid, &child)?; + } else { + // The retained ABI also initializes a fresh kernel instance where + // the parent Process is intentionally absent. Ordinary host-backed + // handles are sole-owned by that child and must not receive a + // phantom parent ref. Kernel-global descriptor backings are not + // serialized, however, so accepting one here could alias a reused + // slot; fail truthfully instead. + if child.ofd_table.iter().any(|(_, ofd)| { + crate::descriptor_backing::manages_ofd(ofd.file_type, ofd.host_handle) + }) { + return Err(Errno::EBADF); + } + } + self.processes.insert(child.pid, child); + Ok(()) + } + + /// Replace an existing process through the retained legacy exec-state + /// ABI, transferring one ownership reference for surviving descriptor + /// backings and releasing old CLOEXEC-only/orphaned OFDs exactly once. + #[cfg_attr( + not(any(target_arch = "wasm32", target_arch = "wasm64")), + allow(dead_code) + )] + pub(crate) fn replace_legacy_exec_process( + &mut self, + pid: u32, + mut replacement: Process, + ) -> Result<(), Errno> { + if replacement.pid != pid { + return Err(Errno::EINVAL); + } + if let Some(old) = self.processes.get(&pid) { + if matches!(old.state, ProcessState::Exited | ProcessState::Limbo) { + return Err(Errno::ESRCH); + } + // Exec replaces the image, not the process identity or its + // job-control state or unconsumed parent-visible status record. + replacement.state = old.state; + replacement.wait_event = old.wait_event; + let removed = crate::descriptor_backing::removed_backings_for_exec(old, &replacement)?; + let old = self.processes.insert(pid, replacement).unwrap(); + crate::descriptor_backing::release_backings(&removed); + drop(old); + } else { + // A fresh kernel instance has no old Process from which to + // transfer global backing ownership, and the retained wire format + // does not serialize those backing values. Reject them instead of + // letting a stale stable index alias this instance's current or + // future allocation at the same slot. + if replacement.ofd_table.iter().any(|(_, ofd)| { + crate::descriptor_backing::manages_ofd(ofd.file_type, ofd.host_handle) + }) { + return Err(Errno::EBADF); + } + self.processes.insert(pid, replacement); + } + Ok(()) + } + /// Non-forking spawn: build a child process for `posix_spawn` without /// going through fork continuation at all. The child is constructed from a /// fresh `Process::new(child_pid)` and selectively inherits only what @@ -618,6 +788,12 @@ impl ProcessTable { // Snapshot inheritable parent state under an immutable borrow. let inherit = { let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; + if matches!( + parent.state, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo + ) { + return Err(Errno::ESRCH); + } // Compute the SIG_IGN-disposition bitmask for signals 1..=64. let mut ignored_signals: u64 = 0; for sig in 1u32..=64 { @@ -726,12 +902,11 @@ impl ProcessTable { } } - self.processes.insert(child_pid, child); - // Bump cross-process refcounts on the inherited fd state. The same // helper fork uses — this is the genuinely-shared concern. - let child_ref = self.processes.get(&child_pid).unwrap(); - bump_inherited_resource_refcounts(child_ref); + bump_inherited_resource_refcounts(parent_pid, &child)?; + + self.processes.insert(child_pid, child); // Apply file actions in forward order against the child. Any failure // rolls back the partial child via remove_process — which runs the @@ -739,6 +914,12 @@ impl ProcessTable { // any newly-opened fds, queues last-ref host net handles for close). if let Err(e) = self.apply_spawn_file_actions(child_pid, file_actions, host) { if let Some(removed) = self.remove_process(child_pid) { + for dir_handle in removed.host_dir_closes { + let _ = host.host_closedir(dir_handle); + } + for handle in removed.host_closes { + let _ = host.host_close(handle); + } for net_handle in removed.host_net_closes { let _ = host.host_net_close(net_handle); } @@ -846,11 +1027,45 @@ impl ProcessTable { self.processes.get(&pid) } - /// Collect all active PIDs. + /// Find the process record that owns a retained Linux-style task ID. + /// + /// A process leader's TID is its PID; pthread TIDs live in the owning + /// Process record. Exited leaders remain addressable until reaped, while a + /// Limbo record is only an internal process-group/session placeholder. + pub fn get_process_containing_task(&self, tid: u32) -> Option<&Process> { + if let Some(leader) = self + .processes + .get(&tid) + .filter(|process| process.state != ProcessState::Limbo) + { + return Some(leader); + } + + self.processes.values().find(|process| { + matches!(process.state, ProcessState::Running | ProcessState::Stopped) + && process.get_thread(tid).is_some() + }) + } + + /// Collect every retained PID, including internal limbo identities. pub fn all_pids(&self) -> Vec { self.processes.keys().copied().collect() } + /// Collect PIDs that should be visible through procfs. + /// + /// Exited processes remain visible as zombies until their parent reaps + /// them. Limbo entries are already reaped and retained only as internal + /// process-group/session identities, so exposing them as `/proc/` + /// would resurrect a process that no longer exists. + pub fn procfs_pids(&self) -> Vec { + self.processes + .iter() + .filter(|(_, proc)| proc.state != ProcessState::Limbo) + .map(|(&pid, _)| pid) + .collect() + } + /// Collect PIDs of all processes in a given process group. pub fn pids_in_group(&self, pgid: u32) -> Vec { self.processes @@ -872,40 +1087,54 @@ impl ProcessTable { /// parent reaps it, so wait semantics stay kernel-owned. pub fn mark_process_signaled(&mut self, pid: u32, signum: u32) -> Result<(), Errno> { let proc = self.processes.get_mut(&pid).ok_or(Errno::ESRCH)?; - proc.state = ProcessState::Exited; - proc.exit_status = 128 + signum as i32; + proc.record_signal_exit(signum); Ok(()) } - /// Poll for a waitable child owned by `parent_pid` matching a waitpid-style - /// target. - /// - /// Returns: - /// - `Ok(Some((child_pid, wait_status)))` when a matching zombie exists. - /// - `Ok(None)` when a matching child exists but is still running. - /// - `Err(ECHILD)` when no matching child belongs to the parent. - pub fn poll_waitable_child( - &self, + /// Select the latest status-information record for a direct child. + /// Nonmatching masks and WNOWAIT leave that single record untouched. + pub fn poll_wait_event( + &mut self, parent_pid: u32, target_pid: i32, - ) -> Result, Errno> { - let parent = self.processes.get(&parent_pid).ok_or(Errno::ESRCH)?; + event_mask: u32, + flags: u32, + ) -> Result, Errno> { + use wasm_posix_shared::wait::{ + EVENT_CONTINUED, EVENT_EXITED, EVENT_STOPPED, WNOWAIT, + }; + + let valid_events = EVENT_EXITED | EVENT_STOPPED | EVENT_CONTINUED; + if event_mask == 0 || event_mask & !valid_events != 0 || flags & !WNOWAIT != 0 { + return Err(Errno::EINVAL); + } + + let parent_pgid = self + .processes + .get(&parent_pid) + .ok_or(Errno::ESRCH)? + .pgid; let mut saw_matching_child = false; - for (&child_pid, child) in &self.processes { - if child.ppid != parent_pid { + for (&child_pid, child) in &mut self.processes { + if child.ppid != parent_pid || child.state == ProcessState::Limbo { continue; } - if !Self::child_matches_wait_target(child_pid, child, target_pid, parent.pgid) { + if !Self::child_matches_wait_target(child_pid, child, target_pid, parent_pgid) { continue; } saw_matching_child = true; - if child.state == ProcessState::Exited { - return Ok(Some(( - child_pid, - Self::wait_status_from_exit_status(child.exit_status), - ))); + + let Some(event) = child.wait_event else { + continue; + }; + if event.event_mask & event_mask == 0 { + continue; } + if flags & WNOWAIT == 0 { + child.wait_event = None; + } + return Ok(Some((child_pid, event))); } if saw_matching_child { @@ -943,14 +1172,6 @@ impl ProcessTable { }; child.pgid == target_pgid } - - fn wait_status_from_exit_status(exit_status: i32) -> i32 { - if exit_status >= 128 { - (exit_status - 128) & 0x7f - } else { - (exit_status & 0xff) << 8 - } - } } #[cfg(test)] @@ -982,12 +1203,22 @@ mod wait_tests { table.processes.get_mut(&102).unwrap().pgid = 101; table.processes.get_mut(&101).unwrap().state = ProcessState::Exited; + assert!( + table.procfs_pids().contains(&101), + "an unreaped zombie remains visible through procfs" + ); + table.reap_process(101).expect("reap group leader"); let limbo = table.get(101).expect("limbo leader retained"); assert_eq!(limbo.state, ProcessState::Limbo); assert_eq!(limbo.pgid, 101); assert_eq!(limbo.ppid, 100); + assert!(table.all_pids().contains(&101)); + assert!( + !table.procfs_pids().contains(&101), + "a reaped limbo identity must not remain visible through procfs" + ); table.processes.get_mut(&102).unwrap().state = ProcessState::Exited; table.reap_process(102).expect("reap final member"); @@ -1041,6 +1272,205 @@ pub fn current_pid() -> u32 { mod tests { use super::*; + #[test] + fn legacy_state_install_rejects_collisions_but_allows_fresh_kernel_tables() { + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + table.get_mut(100).unwrap().argv = alloc::vec![b"original".to_vec()]; + + let mut colliding_fork = Process::new(100); + colliding_fork.ppid = 100; + assert_eq!( + table.insert_legacy_fork_process(colliding_fork), + Err(Errno::EEXIST) + ); + assert_eq!(table.get(100).unwrap().argv[0], b"original"); + + let mut child_without_local_parent = Process::new(101); + child_without_local_parent.ppid = 999; + table + .insert_legacy_fork_process(child_without_local_parent) + .unwrap(); + assert_eq!(table.get(101).unwrap().ppid, 999); + + table + .replace_legacy_exec_process(777, Process::new(777)) + .unwrap(); + assert!(table.get(777).is_some()); + assert_eq!( + table.replace_legacy_exec_process(100, Process::new(102)), + Err(Errno::EINVAL) + ); + assert_eq!(table.get(100).unwrap().argv[0], b"original"); + } + + #[test] + fn legacy_exec_preserves_stopped_state_and_parent_visible_status_record() { + use wasm_posix_shared::signal::SIGTSTP; + use wasm_posix_shared::wait::EVENT_STOPPED; + + let mut table = ProcessTable::new(); + table.create_process(200).unwrap(); + assert!(table.get_mut(200).unwrap().record_stop(SIGTSTP)); + + table + .replace_legacy_exec_process(200, Process::new(200)) + .unwrap(); + + assert_eq!(table.get(200).unwrap().state, ProcessState::Stopped); + let event = table.get(200).unwrap().wait_event.unwrap(); + assert_eq!(event.event_mask, EVENT_STOPPED); + assert_eq!(event.si_status, SIGTSTP as i32); + } + + #[test] + fn fork_pipe_replay_includes_fds_above_default_nofile_limit() { + use crate::fd::OpenFileDescRef; + use wasm_posix_shared::flags::{O_RDONLY, O_WRONLY}; + + let mut child = Process::new(100); + child.fd_table.set_max_fds(4096); + let read_ofd = child + .ofd_table + .create(FileType::Pipe, O_RDONLY, -1, b"pipe-read".to_vec()); + let write_ofd = child + .ofd_table + .create(FileType::Pipe, O_WRONLY, -1, b"pipe-write".to_vec()); + let read_fd = child + .fd_table + .alloc_at_min(OpenFileDescRef(read_ofd), 0, 2048) + .unwrap(); + let write_fd = child + .fd_table + .alloc_at_min(OpenFileDescRef(write_ofd), 0, 2049) + .unwrap(); + + assert_eq!(build_fork_pipe_replay(&child), vec![(read_fd, write_fd)]); + } + + #[test] + fn exited_parent_cannot_fork_or_spawn() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + table.get_mut(100).unwrap().state = crate::process::ProcessState::Exited; + + assert_eq!(table.fork_process(100, 101), Err(Errno::ESRCH)); + let mut host = NoopHost; + assert_eq!( + table.spawn_child( + 100, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ), + Err(Errno::ESRCH), + ); + } + + #[test] + fn stopped_parent_can_finish_spawn_after_async_resolution() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::signal::SIGSTOP; + + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + assert!(table.get_mut(100).unwrap().record_stop(SIGSTOP)); + + // The host resolves a posix_spawn executable asynchronously. A stop + // can land during that await; the parent remains a live process and + // the resolved continuation must still be allowed to create its child. + let mut host = NoopHost; + let child_pid = table + .spawn_child( + 100, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .expect("stopped parent remains eligible to complete spawn"); + + assert_eq!(table.get(100).unwrap().state, ProcessState::Stopped); + assert_eq!(table.get(child_pid).unwrap().ppid, 100); + assert_eq!(table.get(child_pid).unwrap().state, ProcessState::Running); + } + + #[test] + fn process_exit_closes_tcp_pipes_orderly() { + use crate::pipe::{global_pipe_table, PipeBuffer, DEFAULT_PIPE_CAPACITY}; + use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + + let pipe_table = unsafe { global_pipe_table() }; + let send_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); + let recv_idx = pipe_table.alloc(PipeBuffer::new(DEFAULT_PIPE_CAPACITY)); + + let mut table = ProcessTable::new(); + table.create_process(950_001).unwrap(); + let proc = table.processes.get_mut(&950_001).unwrap(); + let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 6); + socket.state = SocketState::Connected; + socket.send_buf_idx = Some(send_idx); + socket.recv_buf_idx = Some(recv_idx); + socket.global_pipes = true; + let sock_idx = proc.sockets.alloc(socket); + let ofd_idx = proc.ofd_table.create( + FileType::Socket, + wasm_posix_shared::flags::O_RDWR, + -((sock_idx as i64) + 1), + Vec::new(), + ); + proc.fd_table + .alloc(crate::fd::OpenFileDescRef(ofd_idx), 0) + .unwrap(); + + table.remove_process(950_001).unwrap(); + + let send_pipe = pipe_table.get_mut(send_idx).unwrap(); + assert!(!send_pipe.is_write_end_open()); + assert!(send_pipe.is_read_end_open()); + let recv_pipe = pipe_table.get_mut(recv_idx).unwrap(); + assert!(recv_pipe.is_read_end_open()); + assert_eq!(recv_pipe.write(b"after-exit-one"), 14); + assert_eq!(recv_pipe.write(b"after-exit-two"), 14); + assert_eq!(recv_pipe.available(), 0); + + pipe_table.get_mut(send_idx).unwrap().close_read_end(); + pipe_table.free_if_closed(send_idx); + pipe_table.get_mut(recv_idx).unwrap().close_write_end(); + pipe_table.free_if_closed(recv_idx); + assert!(pipe_table.get(send_idx).is_none()); + assert!(pipe_table.get(recv_idx).is_none()); + } + + fn install_bound_udp4_socket(table: &mut ProcessTable, pid: u32, port: u16) -> usize { + use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + + let sock_idx = { + let proc = table.processes.get_mut(&pid).unwrap(); + let mut socket = SocketInfo::new(SocketDomain::Inet, SocketType::Dgram, 17); + socket.state = SocketState::Bound; + socket.bind_addr = [127, 0, 0, 1]; + socket.bind_port = port; + proc.sockets.alloc(socket) + }; + crate::socket::udp_register(pid, sock_idx, [127, 0, 0, 1], port, false).unwrap(); + sock_idx + } + + fn assert_udp_owner(port: u16, pid: u32, sock_idx: usize, present: bool) { + let present_in_lookup = crate::socket::udp_lookup([127, 0, 0, 1], port) + .iter() + .any(|target| target.pid == pid && target.sock_idx == sock_idx); + assert_eq!(present_in_lookup, present); + } + #[test] fn fork_process_grows_state_buffer_for_large_parent_state() { const LARGE_FD_COUNT: usize = 80; @@ -1055,7 +1485,7 @@ mod tests { for _ in 0..LARGE_FD_COUNT { let path = alloc::vec![b'x'; LARGE_PATH_LEN]; - let ofd_ref = parent.ofd_table.create(FileType::MemFd, 0, -1, path); + let ofd_ref = parent.ofd_table.create(FileType::Regular, 0, -10, path); last_fd = parent .fd_table .alloc(crate::fd::OpenFileDescRef(ofd_ref), 0) @@ -1084,50 +1514,178 @@ mod tests { let child_ofd = child.ofd_table.get(child_fd.ofd_ref.0).unwrap(); assert_eq!(child.ppid, 100); - assert_eq!(child_ofd.file_type, FileType::MemFd); + assert_eq!(child_ofd.file_type, FileType::Regular); assert_eq!(child_ofd.path.len(), LARGE_PATH_LEN); } #[test] - fn poll_waitable_child_returns_exited_child_status() { + fn fork_inherits_udp_binding_owner_before_parent_exit() { + const PARENT: u32 = 930_001; + const CHILD: u32 = 930_002; + const PORT: u16 = 64_905; + + crate::socket::udp_cleanup_process(PARENT); + crate::socket::udp_cleanup_process(CHILD); + let mut table = ProcessTable::new(); + table.create_process(PARENT).unwrap(); + let sock_idx = install_bound_udp4_socket(&mut table, PARENT, PORT); + + table.fork_process(PARENT, CHILD).unwrap(); + assert_udp_owner(PORT, PARENT, sock_idx, true); + assert_udp_owner(PORT, CHILD, sock_idx, true); + + table.remove_process(PARENT).unwrap(); + assert_udp_owner(PORT, PARENT, sock_idx, false); + assert_udp_owner(PORT, CHILD, sock_idx, true); + assert!(!crate::socket::udp_can_bind( + 930_003, + 0, + [127, 0, 0, 1], + PORT, + false + )); + + table.remove_process(CHILD).unwrap(); + assert!(crate::socket::udp_lookup([127, 0, 0, 1], PORT).is_empty()); + assert!(crate::socket::udp_can_bind( + 930_003, + 0, + [127, 0, 0, 1], + PORT, + false + )); + } + + #[test] + fn spawn_inherits_udp_binding_owner_before_parent_exit() { + use crate::process::test_host::NoopHost; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 940_001; + const PORT: u16 = 64_906; + + crate::socket::udp_cleanup_process(PARENT); + let mut table = ProcessTable::new(); + table.create_process(PARENT).unwrap(); + let sock_idx = install_bound_udp4_socket(&mut table, PARENT, PORT); + let mut host = NoopHost; + + let child_pid = table + .spawn_child( + PARENT, + &[b"/bin/child".as_slice()], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + assert_udp_owner(PORT, PARENT, sock_idx, true); + assert_udp_owner(PORT, child_pid, sock_idx, true); + + table.remove_process(PARENT).unwrap(); + assert_udp_owner(PORT, PARENT, sock_idx, false); + assert_udp_owner(PORT, child_pid, sock_idx, true); + + table.remove_process(child_pid).unwrap(); + assert!(crate::socket::udp_lookup([127, 0, 0, 1], PORT).is_empty()); + } + + #[test] + fn poll_wait_event_selects_and_consumes_exit_status() { + use wasm_posix_shared::wait::{CLD_EXITED, EVENT_EXITED}; + let mut table = ProcessTable::new(); table.create_process(10).unwrap(); table.create_process(11).unwrap(); let child = table.processes.get_mut(&11).unwrap(); child.ppid = 10; - child.state = ProcessState::Exited; - child.exit_status = 7; + assert!(child.record_normal_exit(7)); - assert_eq!( - table.poll_waitable_child(10, -1).unwrap(), - Some((11, 7 << 8)) - ); + let (pid, event) = table + .poll_wait_event(10, -1, EVENT_EXITED, 0) + .unwrap() + .unwrap(); + assert_eq!(pid, 11); + assert_eq!(event.wait_status, 7 << 8); + assert_eq!(event.si_code, CLD_EXITED); + assert_eq!(event.si_status, 7); + assert!(table.get(11).unwrap().wait_event.is_none()); + assert_eq!(table.poll_wait_event(10, -1, EVENT_EXITED, 0), Ok(None)); } #[test] - fn poll_waitable_child_encodes_signal_status() { + fn poll_wait_event_wnowait_repeats_the_same_signal_exit() { + use wasm_posix_shared::wait::{CLD_KILLED, EVENT_EXITED, WNOWAIT}; + let mut table = ProcessTable::new(); table.create_process(10).unwrap(); table.create_process(11).unwrap(); - table.mark_process_signaled(11, 15).unwrap(); table.processes.get_mut(&11).unwrap().ppid = 10; + table.mark_process_signaled(11, 15).unwrap(); - assert_eq!(table.poll_waitable_child(10, 11).unwrap(), Some((11, 15))); + for _ in 0..2 { + let (_, event) = table + .poll_wait_event(10, 11, EVENT_EXITED, WNOWAIT) + .unwrap() + .unwrap(); + assert_eq!(event.wait_status, 15); + assert_eq!(event.si_code, CLD_KILLED); + assert_eq!(event.si_status, 15); + } + assert!(table.get(11).unwrap().wait_event.is_some()); } #[test] - fn poll_waitable_child_distinguishes_running_from_no_child() { + fn poll_wait_event_nonmatching_mask_preserves_latest_record() { + use wasm_posix_shared::signal::SIGTSTP; + use wasm_posix_shared::wait::{EVENT_EXITED, EVENT_STOPPED}; + + let mut table = ProcessTable::new(); + table.create_process(10).unwrap(); + table.create_process(11).unwrap(); + let child = table.processes.get_mut(&11).unwrap(); + child.ppid = 10; + assert!(child.record_stop(SIGTSTP)); + + assert_eq!(table.poll_wait_event(10, -1, EVENT_EXITED, 0), Ok(None)); + assert_eq!( + table.get(11).unwrap().wait_event.unwrap().event_mask, + EVENT_STOPPED + ); + assert!( + table + .poll_wait_event(10, -1, EVENT_STOPPED, 0) + .unwrap() + .is_some() + ); + } + + #[test] + fn poll_wait_event_distinguishes_running_from_no_child_and_validates_input() { + use wasm_posix_shared::wait::{EVENT_EXITED, WNOWAIT}; + let mut table = ProcessTable::new(); table.create_process(10).unwrap(); table.create_process(11).unwrap(); table.processes.get_mut(&11).unwrap().ppid = 10; - assert_eq!(table.poll_waitable_child(10, -1).unwrap(), None); - assert_eq!(table.poll_waitable_child(10, 12), Err(Errno::ECHILD)); + assert_eq!(table.poll_wait_event(10, -1, EVENT_EXITED, 0), Ok(None)); + assert_eq!( + table.poll_wait_event(10, 12, EVENT_EXITED, 0), + Err(Errno::ECHILD) + ); + assert_eq!(table.poll_wait_event(10, -1, 0, 0), Err(Errno::EINVAL)); + assert_eq!( + table.poll_wait_event(10, -1, EVENT_EXITED, WNOWAIT | 2), + Err(Errno::EINVAL) + ); } #[test] - fn poll_waitable_child_matches_process_groups() { + fn poll_wait_event_matches_process_groups() { + use wasm_posix_shared::wait::EVENT_EXITED; + let mut table = ProcessTable::new(); table.create_process(10).unwrap(); table.processes.get_mut(&10).unwrap().pgid = 20; @@ -1136,22 +1694,31 @@ mod tests { let child = table.processes.get_mut(&11).unwrap(); child.ppid = 10; child.pgid = 20; - child.state = ProcessState::Exited; - child.exit_status = 0; + child.record_normal_exit(0); } table.create_process(12).unwrap(); { let child = table.processes.get_mut(&12).unwrap(); child.ppid = 10; child.pgid = 30; - child.state = ProcessState::Exited; - child.exit_status = 1; + child.record_normal_exit(1); } - assert_eq!(table.poll_waitable_child(10, 0).unwrap(), Some((11, 0))); assert_eq!( - table.poll_waitable_child(10, -30).unwrap(), - Some((12, 1 << 8)) + table + .poll_wait_event(10, 0, EVENT_EXITED, 0) + .unwrap() + .unwrap() + .0, + 11 + ); + assert_eq!( + table + .poll_wait_event(10, -30, EVENT_EXITED, 0) + .unwrap() + .unwrap() + .0, + 12 ); } @@ -1221,4 +1788,37 @@ mod tests { ); locks.clear(); } + + #[test] + fn task_lookup_prefers_leaders_and_excludes_dead_worker_threads() { + let mut table = ProcessTable::new(); + table.create_process(100).unwrap(); + table.create_process(200).unwrap(); + table + .get_mut(100) + .unwrap() + .add_thread(crate::process::ThreadInfo::new(900, 0, 0, 0)); + table + .get_mut(100) + .unwrap() + .add_thread(crate::process::ThreadInfo::new(200, 0, 0, 0)); + + assert_eq!(table.get_process_containing_task(100).unwrap().pid, 100); + // An exact process leader wins over a numerically colliding worker TID. + assert_eq!(table.get_process_containing_task(200).unwrap().pid, 200); + assert_eq!(table.get_process_containing_task(900).unwrap().pid, 100); + + table.get_mut(100).unwrap().state = ProcessState::Stopped; + assert_eq!(table.get_process_containing_task(900).unwrap().pid, 100); + table.get_mut(100).unwrap().state = ProcessState::Exited; + assert_eq!(table.get_process_containing_task(100).unwrap().pid, 100); + assert!(table.get_process_containing_task(900).is_none()); + + table.get_mut(200).unwrap().state = ProcessState::Exited; + assert_eq!(table.get_process_containing_task(200).unwrap().pid, 200); + table.get_mut(200).unwrap().state = ProcessState::Limbo; + assert!(table.get_process_containing_task(200).is_none()); + + assert!(table.get_process_containing_task(9999).is_none()); + } } diff --git a/crates/kernel/src/procfs.rs b/crates/kernel/src/procfs.rs index 84844e3d69..d23195ab39 100644 --- a/crates/kernel/src/procfs.rs +++ b/crates/kernel/src/procfs.rs @@ -63,9 +63,9 @@ pub enum ProcfsEntry { Cwd(u32), // /proc//cwd (symlink) Exe(u32), // /proc//exe (symlink) Root_(u32), // /proc//root (symlink) - NetDir, // /proc/net - NetTcp, // /proc/net/tcp - NetUnix, // /proc/net/unix + NetDir(Option), // /proc/net or /proc//net + NetTcp(Option), // /proc/net/tcp or /proc//net/tcp + NetUnix(Option), // /proc/net/unix or /proc//net/unix } impl ProcfsEntry { @@ -90,7 +90,7 @@ impl ProcfsEntry { | ProcfsEntry::PidDir(_) | ProcfsEntry::FdDir(_) | ProcfsEntry::FdInfoDir(_) - | ProcfsEntry::NetDir + | ProcfsEntry::NetDir(_) ) } } @@ -106,9 +106,32 @@ pub const MOUNTS_CONTENT: &[u8] = const MOUNTINFO_CONTENT: &[u8] = b"1 0 0:1 / / rw - kandelo-vfs kandelo-root rw\n2 1 0:2 / /proc rw,nosuid,nodev,noexec - proc proc rw,nosuid,nodev,noexec\n3 1 0:3 / /dev rw,nosuid - devfs devfs rw,nosuid\n"; -/// Extract the pid from a ProcfsEntry (0 for root/net entries). -pub fn entry_pid(entry: &ProcfsEntry) -> u32 { - entry_ids(entry).0 +/// Return the process scope of an entry, if it is under `/proc/`. +/// +/// `Option` is intentional: PID 0 is a process-scoped path that must be +/// validated and rejected, not a sentinel for a global procfs entry. +pub fn entry_pid(entry: &ProcfsEntry) -> Option { + match entry { + ProcfsEntry::PidDir(pid) + | ProcfsEntry::PidMounts(pid) + | ProcfsEntry::PidMountinfo(pid) + | ProcfsEntry::FdDir(pid) + | ProcfsEntry::FdInfoDir(pid) + | ProcfsEntry::Stat(pid) + | ProcfsEntry::Status(pid) + | ProcfsEntry::Cmdline(pid) + | ProcfsEntry::Environ(pid) + | ProcfsEntry::Maps(pid) + | ProcfsEntry::Cwd(pid) + | ProcfsEntry::Exe(pid) + | ProcfsEntry::Root_(pid) => Some(*pid), + ProcfsEntry::FdLink(pid, _) | ProcfsEntry::FdInfo(pid, _) => Some(*pid), + ProcfsEntry::NetDir(pid) | ProcfsEntry::NetTcp(pid) | ProcfsEntry::NetUnix(pid) => *pid, + ProcfsEntry::Root + | ProcfsEntry::Mounts + | ProcfsEntry::SelfLink + | ProcfsEntry::ThreadSelfLink => None, + } } // ── Path matching ─────────────────────────────────────────────────────────── @@ -181,7 +204,7 @@ pub fn match_procfs(path: &[u8], current_pid: u32) -> Option { (current_pid, &after[1..]) } } else if rest == b"net" || rest.starts_with(b"net/") { - return match_net_path(rest); + return match_net_path(rest, None); } else { match_pid_path(rest) }; @@ -245,7 +268,7 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { let fd_str = &rem[7..]; parse_i32(fd_str).map(|fd| ProcfsEntry::FdInfo(pid, fd)) } else if rem == b"net" || rem.starts_with(b"net/") { - match_net_path(rem) + match_net_path(rem, Some(pid)) } else { None } @@ -254,14 +277,14 @@ fn match_pid_subpath(pid: u32, remainder: &[u8]) -> Option { } /// Match /proc/net/* paths. -fn match_net_path(rest: &[u8]) -> Option { +fn match_net_path(rest: &[u8], pid: Option) -> Option { if rest == b"net" || rest == b"net/" { - return Some(ProcfsEntry::NetDir); + return Some(ProcfsEntry::NetDir(pid)); } if rest.starts_with(b"net/") { match &rest[4..] { - b"tcp" => return Some(ProcfsEntry::NetTcp), - b"unix" => return Some(ProcfsEntry::NetUnix), + b"tcp" => return Some(ProcfsEntry::NetTcp(pid)), + b"unix" => return Some(ProcfsEntry::NetUnix(pid)), _ => return None, } } @@ -275,19 +298,26 @@ pub fn generate_stat(proc: &Process) -> Vec { use alloc::format; let name = process_name(proc); - let state = if proc.state == crate::process::ProcessState::Running { - 'R' - } else { - 'Z' + let state = match proc.state { + crate::process::ProcessState::Running => 'R', + crate::process::ProcessState::Stopped => 'T', + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo => 'Z', }; // Linux /proc/pid/stat format (simplified): // pid (comm) state ppid pgrp session tty_nr tpgid flags // minflt cminflt majflt cmajflt utime stime cutime cstime // priority nice num_threads itrealvalue starttime vsize rss ... + // + // Keep both scheduling fields distinct. Several user-space tools (ps, + // procps-compatible libraries, PHP's proc_nice() tests) read the nice + // value from field 19; field 18 is the scheduler priority. Kandelo does + // not have a host CPU scheduler, but exposing the stored POSIX nice value + // in the Linux-compatible procfs slot is observable process metadata. + let priority = 20 + proc.nice; let line = format!( - "{} ({}) {} {} {} {} 0 0 0 0 0 0 0 0 0 0 {} 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", - proc.pid, name, state, proc.ppid, proc.pgid, proc.sid, proc.nice, + "{} ({}) {} {} {} {} 0 0 0 0 0 0 0 0 0 0 0 {} {} 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n", + proc.pid, name, state, proc.ppid, proc.pgid, proc.sid, priority, proc.nice, ); line.into_bytes() } @@ -298,10 +328,12 @@ pub fn generate_status(proc: &Process) -> Vec { let name = process_name(proc); - let state_str = if proc.state == crate::process::ProcessState::Running { - "R (running)" - } else { - "Z (zombie)" + let state_str = match proc.state { + crate::process::ProcessState::Running => "R (running)", + crate::process::ProcessState::Stopped => "T (stopped)", + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo => { + "Z (zombie)" + } }; let content = format!( @@ -336,7 +368,7 @@ pub fn generate_status(proc: &Process) -> Vec { proc.egid, count_open_fds(&proc.fd_table), 1 + proc.threads.len(), // main thread + spawned threads - proc.signals.pending_mask(), + proc.pending_for(proc.pid), proc.signals.blocked, ); content.into_bytes() @@ -394,9 +426,12 @@ pub fn generate_fdinfo(proc: &Process, fd: i32) -> Option> { let entry = proc.fd_table.get(fd).ok()?; let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; + let offset = + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset) + .ok()?; let content = format!( "pos:\t{}\nflags:\t{:o}\nmnt_id:\t0\n", - ofd.offset, ofd.status_flags, + offset, ofd.status_flags, ); Some(content.into_bytes()) } @@ -504,9 +539,9 @@ fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { ProcfsEntry::Cwd(pid) => (*pid, 16), ProcfsEntry::Exe(pid) => (*pid, 17), ProcfsEntry::Root_(pid) => (*pid, 18), - ProcfsEntry::NetDir => (0, 19), - ProcfsEntry::NetTcp => (0, 20), - ProcfsEntry::NetUnix => (0, 21), + ProcfsEntry::NetDir(pid) => (pid.unwrap_or(0), 19), + ProcfsEntry::NetTcp(pid) => (pid.unwrap_or(0), 20), + ProcfsEntry::NetUnix(pid) => (pid.unwrap_or(0), 21), } } @@ -514,7 +549,7 @@ fn entry_ids(entry: &ProcfsEntry) -> (u32, u8) { /// Open a procfs entry. Returns the fd number on success. /// -/// - Regular files: generates content snapshot → stores in proc.procfs_bufs +/// - Regular files: generates a refcounted content snapshot /// - Directories: creates OFD with PROCFS_DIR_HANDLE /// - Symlinks: returns ELOOP (caller should follow the link) pub fn procfs_open( @@ -558,8 +593,7 @@ pub fn procfs_open( if entry.is_dir() { // Validate that the target pid exists for pid-scoped directories - let target_pid = entry_pid(entry); - if target_pid != 0 { + if let Some(target_pid) = entry_pid(entry) { validate_pid(proc, target_pid)?; } let ofd_idx = proc.ofd_table.create( @@ -572,20 +606,33 @@ pub fn procfs_open( if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.dir_host_handle = PROCFS_DIR_HANDLE; } - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - return Ok(fd); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + Err(err) + } + }; } - // Regular file: generate content and store in procfs_bufs + // Regular file: generate one snapshot backing per open file description. let content = generate_content(proc, entry)?; - let buf_idx = alloc_procfs_buf(proc, content); + let buf_idx = crate::descriptor_backing::with_procfs_bufs(|table| { + table.alloc(crate::descriptor_backing::ProcfsBacking::new(content)) + }); let host_handle = procfs_buf_handle(buf_idx); let ofd_idx = proc.ofd_table .create(FileType::Regular, status_flags, host_handle, resolved_path); - let fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags)?; - Ok(fd) + match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), fd_flags) { + Ok(fd) => Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::descriptor_backing::with_procfs_bufs(|table| table.release(buf_idx)); + Err(err) + } + } } /// Generate content for a procfs regular file entry. @@ -630,10 +677,16 @@ fn generate_content(proc: &Process, entry: &ProcfsEntry) -> Result, Errn validate_pid(proc, *pid)?; Ok(MOUNTINFO_CONTENT.to_vec()) } - ProcfsEntry::NetTcp => { + ProcfsEntry::NetTcp(pid) => { + if let Some(pid) = pid { + validate_pid(proc, *pid)?; + } Ok(b" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n".to_vec()) } - ProcfsEntry::NetUnix => { + ProcfsEntry::NetUnix(pid) => { + if let Some(pid) = pid { + validate_pid(proc, *pid)?; + } Ok(b"Num RefCount Protocol Flags Type St Inode Path\n".to_vec()) } _ => Err(Errno::ENOENT), @@ -657,17 +710,18 @@ fn validate_pid(proc: &Process, pid: u32) -> Result<(), Errno> { Err(Errno::ENOENT) } -/// Allocate a procfs buffer slot, reusing freed slots. -fn alloc_procfs_buf(proc: &mut Process, data: Vec) -> usize { - for (i, slot) in proc.procfs_bufs.iter().enumerate() { - if slot.is_none() { - proc.procfs_bufs[i] = Some(data); - return i; - } +/// Validate that a parsed procfs entry names an existing process. +/// +/// Path parsing alone is not existence: Linux procfs only exposes +/// `/proc//...` while that pid has a process-table entry. Callers that +/// service metadata-only operations (stat/access/chdir) must perform the same +/// validation as `procfs_open`, otherwise probes such as +/// `test -r /proc/123/stat` incorrectly succeed for already-reaped pids. +pub fn validate_entry(proc: &Process, entry: &ProcfsEntry) -> Result<(), Errno> { + if let Some(pid) = entry_pid(entry) { + validate_pid(proc, pid)?; } - let idx = proc.procfs_bufs.len(); - proc.procfs_bufs.push(Some(data)); - idx + Ok(()) } // ── Readlink handler ──────────────────────────────────────────────────────── @@ -883,9 +937,10 @@ fn dir_entries( } } } - ProcfsEntry::NetDir => { - entries.push((b"tcp".to_vec(), DT_REG, procfs_ino(0, 20))); - entries.push((b"unix".to_vec(), DT_REG, procfs_ino(0, 21))); + ProcfsEntry::NetDir(pid) => { + let ino_pid = pid.unwrap_or(0); + entries.push((b"tcp".to_vec(), DT_REG, procfs_ino(ino_pid, 20))); + entries.push((b"unix".to_vec(), DT_REG, procfs_ino(ino_pid, 21))); } _ => return Err(Errno::ENOTDIR), } @@ -1019,12 +1074,33 @@ mod tests { #[test] fn test_match_procfs_net() { assert_eq!(match_procfs(b"/proc/mounts", 1), Some(ProcfsEntry::Mounts)); - assert_eq!(match_procfs(b"/proc/net", 1), Some(ProcfsEntry::NetDir)); - assert_eq!(match_procfs(b"/proc/net/tcp", 1), Some(ProcfsEntry::NetTcp)); + assert_eq!( + match_procfs(b"/proc/net", 1), + Some(ProcfsEntry::NetDir(None)) + ); + assert_eq!( + match_procfs(b"/proc/net/tcp", 1), + Some(ProcfsEntry::NetTcp(None)) + ); assert_eq!( match_procfs(b"/proc/net/unix", 1), - Some(ProcfsEntry::NetUnix) + Some(ProcfsEntry::NetUnix(None)) ); + assert_eq!( + match_procfs(b"/proc/42/net", 1), + Some(ProcfsEntry::NetDir(Some(42))) + ); + assert_eq!( + match_procfs(b"/proc/42/net/tcp", 1), + Some(ProcfsEntry::NetTcp(Some(42))) + ); + } + + #[test] + fn test_entry_pid_distinguishes_global_entries_from_pid_zero() { + assert_eq!(entry_pid(&ProcfsEntry::NetDir(None)), None); + assert_eq!(entry_pid(&ProcfsEntry::PidDir(0)), Some(0)); + assert_eq!(entry_pid(&ProcfsEntry::NetDir(Some(0))), Some(0)); } #[test] @@ -1046,7 +1122,10 @@ mod tests { let stat = generate_stat(&proc); let stat_str = core::str::from_utf8(&stat).unwrap(); assert!(stat_str.starts_with("42 (test_program) R 1 42 1")); - assert!(stat_str.contains(" 5 ")); // nice value + let after_comm = stat_str.split(") ").nth(1).unwrap(); + let mut fields = after_comm.split_whitespace(); + assert_eq!(fields.nth(15).unwrap(), "25"); // field 18: scheduler priority + assert_eq!(fields.next().unwrap(), "5"); // field 19: nice value } #[test] @@ -1054,12 +1133,30 @@ mod tests { let mut proc = Process::new(1); proc.argv.push(b"init".to_vec()); proc.umask = 0o022; + proc.signals.raise(2); + proc.main_thread_signals.raise(25); let status = generate_status(&proc); let status_str = core::str::from_utf8(&status).unwrap(); assert!(status_str.contains("Name:\tinit\n")); assert!(status_str.contains("Pid:\t1\n")); assert!(status_str.contains("Umask:\t0022\n")); + assert!(status_str.contains("SigPnd:\t0000000001000002\n")); + } + + #[test] + fn stopped_process_uses_linux_t_state_in_stat_and_status() { + let mut proc = Process::new(44); + proc.argv.push(b"sleeping".to_vec()); + proc.state = crate::process::ProcessState::Stopped; + + let stat = generate_stat(&proc); + let stat = core::str::from_utf8(&stat).unwrap(); + assert!(stat.starts_with("44 (sleeping) T ")); + + let status = generate_status(&proc); + let status = core::str::from_utf8(&status).unwrap(); + assert!(status.contains("State:\tT (stopped)\n")); } #[test] @@ -1149,14 +1246,14 @@ mod tests { let fd = procfs_open(&mut proc, &entry, b"/proc/1/stat".to_vec(), 0).unwrap(); assert!(fd >= 0); - // Verify buffer was stored - assert!(!proc.procfs_bufs.is_empty()); - assert!(proc.procfs_bufs[0].is_some()); - // Verify OFD has procfs buf handle let fe = proc.fd_table.get(fd).unwrap(); let ofd = proc.ofd_table.get(fe.ofd_ref.0).unwrap(); assert!(is_procfs_buf_handle(ofd.host_handle)); + assert!(crate::descriptor_backing::with_procfs_bufs(|table| table + .get(procfs_buf_idx(ofd.host_handle)) + .is_some_and(|backing| !backing.data.is_empty()))); + crate::descriptor_backing::release_for_ofd(ofd.file_type, ofd.host_handle); } #[test] diff --git a/crates/kernel/src/signal.rs b/crates/kernel/src/signal.rs index 52924020ea..686c0b377f 100644 --- a/crates/kernel/src/signal.rs +++ b/crates/kernel/src/signal.rs @@ -3,6 +3,8 @@ extern crate alloc; use alloc::collections::VecDeque; +use crate::process::{HostIO, Process, ProcessState}; + /// First real-time signal number. pub const SIGRTMIN: u32 = 32; /// Last real-time signal number (exclusive upper bound for iteration). @@ -52,8 +54,8 @@ pub enum DefaultAction { Terminate, Ignore, CoreDump, // Treated as terminate in Wasm - Stop, // Not supported in Wasm - Continue, // Not supported in Wasm + Stop, + Continue, } /// Get the POSIX default action for a signal number. @@ -64,19 +66,114 @@ pub fn default_action(signum: u32) -> DefaultAction { | SIGUSR1 | SIGUSR2 | SIGPIPE | SIGALRM | SIGTERM => DefaultAction::Terminate, SIGCHLD | SIGWINCH => DefaultAction::Ignore, SIGCONT => DefaultAction::Continue, - SIGSTOP | SIGTSTP => DefaultAction::Stop, + SIGSTOP | SIGTSTP | SIGTTIN | SIGTTOU => DefaultAction::Stop, // Unrecognized signals default to terminate _ if signum >= 1 && signum < NSIG => DefaultAction::Terminate, _ => DefaultAction::Terminate, } } +#[derive(Clone, Copy, PartialEq, Eq)] +pub(crate) enum DefaultSignalOutcome { + Continue, + Stopped, + Exited, +} + +/// Finish a signal-caused process exit, including resource cleanup, before +/// publishing the parent-visible exit record. +pub(crate) fn terminate_process_by_signal( + proc: &mut Process, + host: &mut dyn HostIO, + signum: u32, +) { + proc.sigsuspend_saved_mask = None; + for thread in &mut proc.threads { + thread.signals.sigsuspend_saved_mask = None; + } + crate::syscalls::sys_exit_by_signal(proc, host, signum); +} + +/// Apply a signal's default action after its pending instance has been +/// consumed. SIGCONT's mandatory resume already happened at generation time. +pub(crate) fn apply_default_signal_action( + proc: &mut Process, + host: &mut dyn HostIO, + signum: u32, +) -> DefaultSignalOutcome { + match default_action(signum) { + DefaultAction::Terminate | DefaultAction::CoreDump => { + terminate_process_by_signal(proc, host, signum); + DefaultSignalOutcome::Exited + } + DefaultAction::Stop => { + if proc.record_stop(signum) { + DefaultSignalOutcome::Stopped + } else { + DefaultSignalOutcome::Continue + } + } + DefaultAction::Continue | DefaultAction::Ignore => DefaultSignalOutcome::Continue, + } +} + +/// Consume one pending instance for `tid`, preferring its directed queue. +pub(crate) fn dequeue_signal_for( + proc: &mut Process, + tid: u32, + signum: u32, +) -> (u32, i32, i32, i32, i32) { + if proc.state == ProcessState::Stopped && signum == wasm_posix_shared::signal::SIGKILL { + proc.clear_signal_everywhere(signum); + return (signum, 0, 0, proc.pid as i32, proc.uid as i32); + } + let info = proc.consume_signal_for(tid, signum).unwrap_or_default(); + let (word_1, word_2) = match info.timer_id { + Some(timer_id) => ( + timer_id as i32, + proc.accept_posix_timer_notification(timer_id).unwrap_or(0), + ), + None => (proc.pid as i32, proc.uid as i32), + }; + (signum, info.si_value, info.si_code, word_1, word_2) +} + +/// Consume default/ignored pending signals at a syscall boundary. Caught +/// signals stay queued for the guest glue. While stopped, Process selection +/// exposes only SIGKILL; SIGCONT has already resumed at generation time. +pub(crate) fn deliver_pending_signals(proc: &mut Process, host: &mut dyn HostIO) { + let tid = crate::process_table::current_tid(); + loop { + let Some(signum) = proc.next_deliverable_signal(tid) else { + break; + }; + let action = proc.signals.get_action(signum); + match action.handler { + SignalHandler::Handler(_) => break, + SignalHandler::Default => { + let _ = dequeue_signal_for(proc, tid, signum); + if apply_default_signal_action(proc, host, signum) + != DefaultSignalOutcome::Continue + { + break; + } + } + SignalHandler::Ignore => { + let _ = dequeue_signal_for(proc, tid, signum); + } + } + if matches!(proc.state, ProcessState::Stopped | ProcessState::Exited) { + break; + } + } +} + /// Check whether setting this handler should discard pending signals. /// POSIX: "Setting a signal action to SIG_IGN for a signal that is pending shall /// cause the pending signal to be discarded." Also: "Setting a signal action to /// SIG_DFL for a signal that is pending and whose default action is to ignore the /// signal (for example, SIGCHLD), shall cause the pending signal to be discarded." -fn should_discard_pending(signum: u32, handler: &SignalHandler) -> bool { +pub(crate) fn should_discard_pending(signum: u32, handler: &SignalHandler) -> bool { match handler { SignalHandler::Ignore => true, SignalHandler::Default => default_action(signum) == DefaultAction::Ignore, @@ -84,13 +181,68 @@ fn should_discard_pending(signum: u32, handler: &SignalHandler) -> bool { } } -/// Queued RT signal entry: signal number + optional si_value. +/// Queued signal instance and its siginfo metadata. #[derive(Debug, Clone, Copy)] pub struct RtSigEntry { pub signum: u32, pub si_value: i32, /// SI_QUEUE (-1) if sent via sigqueue(), SI_USER (0) if via kill()/raise(). pub si_code: i32, + /// Owning POSIX timer for SI_TIMER notifications. + pub timer_id: Option, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct PendingSignalInfo { + pub si_value: i32, + pub si_code: i32, + pub timer_id: Option, +} + +fn consume_pending_info( + pending: &mut u64, + queue: &mut VecDeque, + signum: u32, +) -> PendingSignalInfo { + if signum == 0 || signum >= NSIG { + return PendingSignalInfo::default(); + } + + let info = if let Some(pos) = queue.iter().position(|entry| entry.signum == signum) { + let entry = queue.remove(pos).expect("queued signal index remains valid"); + PendingSignalInfo { + si_value: entry.si_value, + si_code: entry.si_code, + timer_id: entry.timer_id, + } + } else { + PendingSignalInfo::default() + }; + + if !queue.iter().any(|entry| entry.signum == signum) { + *pending &= !sig_bit(signum); + } + info +} + +fn remove_timer_info( + pending: &mut u64, + queue: &mut VecDeque, + timer_id: u32, +) -> bool { + let signum = match queue + .iter() + .find(|entry| entry.timer_id == Some(timer_id)) + .map(|entry| entry.signum) + { + Some(signum) => signum, + None => return false, + }; + queue.retain(|entry| entry.timer_id != Some(timer_id)); + if !queue.iter().any(|entry| entry.signum == signum) { + *pending &= !sig_bit(signum); + } + true } /// Per-thread signal state: pending signals + blocked mask + RT queue. @@ -112,7 +264,7 @@ pub struct PerThreadSignalState { /// Standard signals (1-31) coalesce; RT signals (32-63) set the bit and /// queue one entry per raise in [`rt_queue`]. pub pending: u64, - /// Queue of RT-signal (and sigqueue-style standard-signal) entries + /// Queue of RT-signal and metadata-bearing standard-signal entries /// directed at this thread. Parallel bookkeeping to [`SignalState::rt_queue`]. pub rt_queue: VecDeque, /// Saved blocked mask during sigsuspend / ppoll / pselect (per-thread). @@ -146,30 +298,70 @@ impl PerThreadSignalState { if signum == 0 || signum >= NSIG { return false; } - self.pending |= sig_bit(signum); if signum >= SIGRTMIN { // RT signals: always queue (multiple instances allowed) self.rt_queue.push_back(RtSigEntry { signum, si_value, si_code, + timer_id: None, }); - } else if si_code != 0 { - // Standard signal from sigqueue: store metadata (coalesced). - if let Some(entry) = self.rt_queue.iter_mut().find(|e| e.signum == signum) { + } else if let Some(entry) = self + .rt_queue + .iter_mut() + .find(|entry| entry.signum == signum && entry.timer_id.is_none()) + { + // Standard non-timer signals coalesce independently of timer + // notifications using the same signal number. + if si_code != 0 { entry.si_value = si_value; entry.si_code = si_code; - } else { - self.rt_queue.push_back(RtSigEntry { - signum, - si_value, - si_code, - }); } + } else { + self.rt_queue.push_back(RtSigEntry { + signum, + si_value, + si_code, + timer_id: None, + }); } + self.pending |= sig_bit(signum); true } + pub(crate) fn raise_timer(&mut self, signum: u32, si_value: i32, timer_id: u32) -> bool { + if signum == 0 || signum >= NSIG { + return false; + } + if signum < SIGRTMIN + && (self.pending & sig_bit(signum)) != 0 + && !self.rt_queue.iter().any(|entry| entry.signum == signum) + { + self.rt_queue.push_back(RtSigEntry { + signum, + si_value: 0, + si_code: 0, + timer_id: None, + }); + } + self.pending |= sig_bit(signum); + self.rt_queue.push_back(RtSigEntry { + signum, + si_value, + si_code: -2, + timer_id: Some(timer_id), + }); + true + } + + pub(crate) fn consume_one_info(&mut self, signum: u32) -> PendingSignalInfo { + consume_pending_info(&mut self.pending, &mut self.rt_queue, signum) + } + + pub(crate) fn remove_timer_notification(&mut self, timer_id: u32) -> bool { + remove_timer_info(&mut self.pending, &mut self.rt_queue, timer_id) + } + /// Clear all pending instances of a signal from this thread's pending set. pub fn clear_pending(&mut self, signum: u32) { if signum > 0 && signum < NSIG { @@ -178,6 +370,24 @@ impl PerThreadSignalState { } } + /// Consume one directed instance of `signum` regardless of its blocked + /// state. Standard signals coalesce; RT signals retain the pending bit + /// until their final queued instance is consumed. + pub fn consume_one(&mut self, signum: u32) -> Option<(i32, i32)> { + if signum == 0 + || signum >= NSIG + || self.pending & sig_bit(signum) == 0 + { + return None; + } + let info = self.consume_one_info(signum); + Some((info.si_value, info.si_code)) + } + + pub fn is_pending(&self, signum: u32) -> bool { + signum > 0 && signum < NSIG && (self.pending & sig_bit(signum)) != 0 + } + /// Check whether a signal is blocked by this thread. pub fn is_blocked(&self, signum: u32) -> bool { if signum >= NSIG { @@ -197,19 +407,7 @@ impl PerThreadSignalState { if signum >= NSIG { return None; } - let (mut si_value, mut si_code) = (0i32, 0i32); - if let Some(pos) = self.rt_queue.iter().position(|e| e.signum == signum) { - si_value = self.rt_queue[pos].si_value; - si_code = self.rt_queue[pos].si_code; - self.rt_queue.remove(pos); - } - if signum >= SIGRTMIN { - if !self.rt_queue.iter().any(|e| e.signum == signum) { - self.pending &= !sig_bit(signum); - } - } else { - self.pending &= !sig_bit(signum); - } + let (si_value, si_code) = self.consume_one(signum)?; Some((signum, si_value, si_code)) } } @@ -299,6 +497,28 @@ impl SignalState { Ok(old) } + /// Apply exec disposition rules without rebuilding pending-signal state. + /// + /// Caught dispositions reset to default and ignored dispositions remain + /// ignored. All per-action flags and masks belong to the old image and are + /// cleared, including metadata attached to SIG_DFL and SIG_IGN entries. + /// The process blocked mask, pending bitset, RT queue multiplicity, and + /// queued siginfo metadata deliberately remain untouched. + pub fn reset_dispositions_for_exec(&mut self) { + for action in self.actions.iter_mut().skip(1) { + let handler = if matches!(action.handler, SignalHandler::Ignore) { + SignalHandler::Ignore + } else { + SignalHandler::Default + }; + *action = SignalAction { + handler, + flags: 0, + mask: 0, + }; + } + } + /// Mark a signal as pending (via kill/raise — SI_USER). /// Standard signals (1-31) are coalesced. RT signals (32-63) are queued. /// Bit position = signum - 1 (musl convention: signal N uses bit N-1). @@ -315,29 +535,62 @@ impl SignalState { if signum == 0 || signum >= 65 { return false; } - self.pending |= sig_bit(signum); + if should_discard_pending(signum, &self.actions[signum as usize].handler) { + return true; + } if signum >= SIGRTMIN { // RT signals: always queue (multiple instances allowed) self.rt_queue.push_back(RtSigEntry { signum, si_value, si_code, + timer_id: None, }); - } else if si_code != 0 { - // Standard signal from sigqueue (si_code=SI_QUEUE=-1): store metadata - // so si_value/si_code are preserved on delivery. Only one entry per - // standard signal (coalesced), so replace any existing entry. - if let Some(entry) = self.rt_queue.iter_mut().find(|e| e.signum == signum) { + } else if let Some(entry) = self + .rt_queue + .iter_mut() + .find(|entry| entry.signum == signum && entry.timer_id.is_none()) + { + // Standard non-timer signals coalesce independently of timer + // notifications using the same signal number. + if si_code != 0 { entry.si_value = si_value; entry.si_code = si_code; - } else { - self.rt_queue.push_back(RtSigEntry { - signum, - si_value, - si_code, - }); } + } else { + self.rt_queue.push_back(RtSigEntry { + signum, + si_value, + si_code, + timer_id: None, + }); } + self.pending |= sig_bit(signum); + true + } + + pub(crate) fn raise_timer(&mut self, signum: u32, si_value: i32, timer_id: u32) -> bool { + if signum == 0 || signum >= NSIG { + return false; + } + if signum < SIGRTMIN + && (self.pending & sig_bit(signum)) != 0 + && !self.rt_queue.iter().any(|entry| entry.signum == signum) + { + self.rt_queue.push_back(RtSigEntry { + signum, + si_value: 0, + si_code: 0, + timer_id: None, + }); + } + self.pending |= sig_bit(signum); + self.rt_queue.push_back(RtSigEntry { + signum, + si_value, + si_code: -2, + timer_id: Some(timer_id), + }); true } @@ -363,6 +616,17 @@ impl SignalState { self.pending } + pub(crate) fn pending_timer_ids(&self, signum: u32) -> impl Iterator + '_ { + self.rt_queue + .iter() + .filter(move |entry| entry.signum == signum) + .filter_map(|entry| entry.timer_id) + } + + pub(crate) fn remove_timer_notification(&mut self, timer_id: u32) -> bool { + remove_timer_info(&mut self.pending, &mut self.rt_queue, timer_id) + } + /// Clear a signal from the pending set. /// Removes all queued instances (RT or standard sigqueue metadata). pub fn clear_pending(&mut self, signum: u32) { @@ -377,26 +641,8 @@ impl SignalState { /// For RT signals, removes one queued instance; clears pending bit only when /// no more instances remain. For standard signals, clears the pending bit. /// Returns (si_value, si_code) of the consumed signal instance. - pub fn consume_one(&mut self, signum: u32) -> (i32, i32) { - if signum == 0 || signum >= NSIG { - return (0, 0); - } - let (mut si_value, mut si_code) = (0i32, 0i32); - if let Some(pos) = self.rt_queue.iter().position(|e| e.signum == signum) { - si_value = self.rt_queue[pos].si_value; - si_code = self.rt_queue[pos].si_code; - self.rt_queue.remove(pos); - } - if signum >= SIGRTMIN { - // RT signals: only clear pending bit when all instances consumed - if !self.rt_queue.iter().any(|e| e.signum == signum) { - self.pending &= !sig_bit(signum); - } - } else { - // Standard signals: always clear pending bit (coalesced) - self.pending &= !sig_bit(signum); - } - (si_value, si_code) + pub fn consume_one(&mut self, signum: u32) -> PendingSignalInfo { + consume_pending_info(&mut self.pending, &mut self.rt_queue, signum) } /// Check if a signal is blocked. @@ -434,29 +680,8 @@ impl SignalState { } // trailing_zeros gives 0-based bit position; signal number = bit + 1 let signum = deliverable.trailing_zeros() + 1; - let (mut si_value, mut si_code) = (0i32, 0i32); - if signum >= SIGRTMIN { - // RT signal: dequeue one instance from the queue - if let Some(pos) = self.rt_queue.iter().position(|e| e.signum == signum) { - si_value = self.rt_queue[pos].si_value; - si_code = self.rt_queue[pos].si_code; - self.rt_queue.remove(pos); - } - // Only clear the pending bit if no more instances remain - if !self.rt_queue.iter().any(|e| e.signum == signum) { - self.pending &= !sig_bit(signum); - } - } else { - // Standard signal: clear from pending bitmask - self.pending &= !sig_bit(signum); - // Check for sigqueue metadata (stored in rt_queue for standard signals too) - if let Some(pos) = self.rt_queue.iter().position(|e| e.signum == signum) { - si_value = self.rt_queue[pos].si_value; - si_code = self.rt_queue[pos].si_code; - self.rt_queue.remove(pos); - } - } - Some((signum, si_value, si_code)) + let info = self.consume_one(signum); + Some((signum, info.si_value, info.si_code)) } /// Check if the next deliverable signal has SA_RESTART set. @@ -508,14 +733,15 @@ impl SignalState { for (i, h) in handlers.iter().enumerate() { actions[i].handler = *h; } - // Reconstruct RT queue from pending bits (one instance per signal) + // Reconstruct one queued instance for each preserved pending signal. let mut rt_queue = VecDeque::new(); - for sig in SIGRTMIN..SIGRTMAX_PLUS1 { + for sig in 1..SIGRTMAX_PLUS1 { if (pending & sig_bit(sig)) != 0 { rt_queue.push_back(RtSigEntry { signum: sig, si_value: 0, si_code: 0, + timer_id: None, }); } } @@ -603,6 +829,47 @@ mod tests { assert_eq!(default_action(SIGCHLD), DefaultAction::Ignore); assert_eq!(default_action(SIGCONT), DefaultAction::Continue); assert_eq!(default_action(SIGSTOP), DefaultAction::Stop); + assert_eq!(default_action(SIGTSTP), DefaultAction::Stop); + assert_eq!(default_action(SIGTTIN), DefaultAction::Stop); + assert_eq!(default_action(SIGTTOU), DefaultAction::Stop); + } + + #[test] + fn stopped_process_retains_non_kill_signals_pending() { + use crate::process::{Process, ProcessState, test_host::NoopHost}; + use wasm_posix_shared::wait::EVENT_STOPPED; + + let mut proc = Process::new(51); + let mut host = NoopHost; + assert!(proc.record_stop(SIGTSTP)); + assert!(proc.raise_signal(SIGTERM)); + + deliver_pending_signals(&mut proc, &mut host); + + assert_eq!(proc.state, ProcessState::Stopped); + assert!(proc.signals.is_pending(SIGTERM)); + assert_eq!(proc.wait_event.unwrap().event_mask, EVENT_STOPPED); + } + + #[test] + fn sigkill_terminates_a_stopped_process() { + use crate::process::{Process, ProcessState, ThreadInfo, test_host::NoopHost}; + use wasm_posix_shared::wait::{CLD_KILLED, EVENT_EXITED}; + + let mut proc = Process::new(52); + let mut host = NoopHost; + assert!(proc.record_stop(SIGTSTP)); + proc.add_thread(ThreadInfo::new(99, 0, 0, 0)); + assert!(proc.raise_for_thread(99, SIGKILL)); + + deliver_pending_signals(&mut proc, &mut host); + + assert_eq!(proc.state, ProcessState::Exited); + assert!(!proc.signal_pending_anywhere(SIGKILL)); + let event = proc.wait_event.unwrap(); + assert_eq!(event.event_mask, EVENT_EXITED); + assert_eq!(event.si_code, CLD_KILLED); + assert_eq!(event.si_status, SIGKILL as i32); } #[test] @@ -690,6 +957,53 @@ mod tests { assert_eq!(current.mask, 0x04); } + #[test] + fn test_exec_resets_action_metadata_and_preserves_only_ignore() { + let mut state = SignalState::new(); + state + .set_action( + SIGINT, + SignalAction { + handler: SignalHandler::Handler(42), + flags: wasm_posix_shared::signal::SA_RESTART, + mask: 0x04, + }, + ) + .unwrap(); + state + .set_action( + SIGTERM, + SignalAction { + handler: SignalHandler::Ignore, + flags: wasm_posix_shared::signal::SA_RESTART, + mask: 0x08, + }, + ) + .unwrap(); + state.actions[wasm_posix_shared::signal::SIGCHLD as usize] = SignalAction { + handler: SignalHandler::Default, + flags: wasm_posix_shared::signal::SA_NOCLDWAIT, + mask: 0x10, + }; + + state.reset_dispositions_for_exec(); + + let caught = state.get_action(SIGINT); + assert!(matches!(caught.handler, SignalHandler::Default)); + assert_eq!(caught.flags, 0); + assert_eq!(caught.mask, 0); + + let ignored = state.get_action(SIGTERM); + assert!(matches!(ignored.handler, SignalHandler::Ignore)); + assert_eq!(ignored.flags, 0); + assert_eq!(ignored.mask, 0); + + let defaulted = state.get_action(wasm_posix_shared::signal::SIGCHLD); + assert!(matches!(defaulted.handler, SignalHandler::Default)); + assert_eq!(defaulted.flags, 0); + assert_eq!(defaulted.mask, 0); + } + #[test] fn test_set_action_cannot_change_sigkill() { let mut state = SignalState::new(); @@ -739,6 +1053,16 @@ mod tests { assert!(!state.is_pending(SIGUSR1)); } + #[test] + fn test_sig_ign_discards_new_generation_even_while_blocked() { + let mut state = SignalState::new(); + state.blocked = sig_bit(SIGUSR1); + state.set_handler(SIGUSR1, SignalHandler::Ignore).unwrap(); + + assert!(state.raise(SIGUSR1)); + assert!(!state.is_pending(SIGUSR1)); + } + #[test] fn test_sig_dfl_discards_pending_for_ignored_signal() { // SIGCHLD default action is Ignore, so SIG_DFL should discard pending @@ -773,6 +1097,73 @@ mod tests { assert_eq!(state.dequeue(), None); } + #[test] + fn test_timer_notifications_are_queued_per_timer() { + let mut state = SignalState::new(); + state.raise_timer(SIGUSR1, 41, 3); + state.raise_timer(SIGUSR1, 42, 4); + + let first = state.consume_one(SIGUSR1); + assert_eq!(first.si_code, -2); + assert_eq!(first.si_value, 41); + assert_eq!(first.timer_id, Some(3)); + assert!(state.is_pending(SIGUSR1)); + + let second = state.consume_one(SIGUSR1); + assert_eq!(second.si_code, -2); + assert_eq!(second.si_value, 42); + assert_eq!(second.timer_id, Some(4)); + assert!(!state.is_pending(SIGUSR1)); + } + + #[test] + fn test_removing_one_timer_preserves_other_pending_notification() { + let mut state = SignalState::new(); + state.raise_timer(SIGUSR1, 41, 3); + state.raise_timer(SIGUSR1, 42, 4); + + assert!(state.remove_timer_notification(3)); + assert!(state.is_pending(SIGUSR1)); + let remaining = state.consume_one(SIGUSR1); + assert_eq!(remaining.timer_id, Some(4)); + assert_eq!(remaining.si_value, 42); + assert!(!state.is_pending(SIGUSR1)); + } + + #[test] + fn test_timer_and_sigqueue_metadata_do_not_collide() { + let mut state = SignalState::new(); + state.raise_timer(SIGUSR1, 41, 3); + state.raise_with_value(SIGUSR1, 99); + + let timer = state.consume_one(SIGUSR1); + assert_eq!(timer.timer_id, Some(3)); + assert_eq!(timer.si_code, -2); + assert_eq!(timer.si_value, 41); + assert!(state.is_pending(SIGUSR1)); + + let queued = state.consume_one(SIGUSR1); + assert_eq!(queued.timer_id, None); + assert_eq!(queued.si_code, -1); + assert_eq!(queued.si_value, 99); + assert!(!state.is_pending(SIGUSR1)); + } + + #[test] + fn test_removing_timer_preserves_plain_standard_signal() { + let mut state = SignalState::new(); + state.raise_timer(SIGUSR1, 41, 3); + state.raise(SIGUSR1); + + assert!(state.remove_timer_notification(3)); + assert!(state.is_pending(SIGUSR1)); + let plain = state.consume_one(SIGUSR1); + assert_eq!(plain.timer_id, None); + assert_eq!(plain.si_code, 0); + assert_eq!(plain.si_value, 0); + assert!(!state.is_pending(SIGUSR1)); + } + #[test] fn test_block_raise_ignore_unignore_unblock() { // Mimics sortix signal/block-raise-ignore-unignore-unblock test diff --git a/crates/kernel/src/socket.rs b/crates/kernel/src/socket.rs index fa5acd0423..0bf5795d99 100644 --- a/crates/kernel/src/socket.rs +++ b/crates/kernel/src/socket.rs @@ -91,10 +91,27 @@ pub enum SocketState { pub struct Datagram { pub data: Vec, pub src_addr: [u8; 4], + pub src_addr6: [u8; 16], + pub dst_addr: [u8; 4], + pub dst_addr6: [u8; 16], pub src_port: u16, + pub src_sock_idx: Option, + /// IPv6 traffic class associated with this datagram. + pub ipv6_tclass: u32, + /// Sender credentials captured when the datagram was queued. AF_UNIX + /// SO_PASSCRED reports these with SCM_CREDENTIALS. + pub src_pid: u32, + pub src_uid: u32, + pub src_gid: u32, + /// Ancillary file descriptors sent with this datagram via SCM_RIGHTS. + pub ancillary_fds: Vec, } -/// One AF_INET UDP endpoint bound in the in-kernel virtual network. +/// One process-local delivery target for an AF_INET UDP binding. +/// +/// A logical binding can have more than one target after fork/spawn. The +/// binding table keeps those owners together so closing one inherited copy +/// does not release the port while another process still owns the socket. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct UdpEndpoint { pub pid: u32, @@ -104,12 +121,81 @@ pub struct UdpEndpoint { pub reuse_addr: bool, } -struct UdpEndpointTable(UnsafeCell>>); +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct BindingOwner { + pid: u32, + sock_idx: usize, +} + +trait HasBindingOwners { + fn owners(&self) -> &[BindingOwner]; + fn owners_mut(&mut self) -> &mut Vec; +} + +fn remove_binding_owner(bindings: &mut Vec, pid: u32, sock_idx: usize) { + for binding in bindings.iter_mut() { + binding + .owners_mut() + .retain(|owner| owner.pid != pid || owner.sock_idx != sock_idx); + } + bindings.retain(|binding| !binding.owners().is_empty()); +} + +fn cleanup_binding_owner_pid(bindings: &mut Vec, pid: u32) { + for binding in bindings.iter_mut() { + binding.owners_mut().retain(|owner| owner.pid != pid); + } + bindings.retain(|binding| !binding.owners().is_empty()); +} + +fn inherit_binding_owner( + bindings: &mut [T], + parent: BindingOwner, + child: BindingOwner, +) { + for binding in bindings.iter_mut() { + if binding.owners().contains(&parent) && !binding.owners().contains(&child) { + binding.owners_mut().push(child); + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct UdpBinding { + owners: Vec, + addr: [u8; 4], + port: u16, + reuse_addr: bool, +} + +impl HasBindingOwners for UdpBinding { + fn owners(&self) -> &[BindingOwner] { + &self.owners + } + + fn owners_mut(&mut self) -> &mut Vec { + &mut self.owners + } +} + +/// IPv4 multicast group state for an AF_INET datagram socket. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Ipv4MulticastMembership { + pub group: [u8; 4], + /// Interface address used for matching local delivery. 0.0.0.0 means the + /// kernel default interface. 127.0.0.1 represents loopback. + pub interface_addr: [u8; 4], + pub any_source: bool, + pub blocked_sources: Vec<[u8; 4]>, + pub included_sources: Vec<[u8; 4]>, +} + +struct UdpEndpointTable(UnsafeCell>>); unsafe impl Sync for UdpEndpointTable {} static UDP_ENDPOINTS: UdpEndpointTable = UdpEndpointTable(UnsafeCell::new(None)); -fn udp_endpoints() -> &'static mut Vec { +fn udp_bindings() -> &'static mut Vec { let opt = unsafe { &mut *UDP_ENDPOINTS.0.get() }; opt.get_or_insert_with(Vec::new) } @@ -123,13 +209,20 @@ fn udp_addr_matches(bound: [u8; 4], dst: [u8; 4]) -> bool { } pub fn udp_can_bind(pid: u32, sock_idx: usize, addr: [u8; 4], port: u16, reuse_addr: bool) -> bool { - for endpoint in udp_endpoints().iter() { - if endpoint.pid == pid && endpoint.sock_idx == sock_idx { + for binding in udp_bindings().iter() { + let caller_owns_binding = binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx); + // Binding this same process-local socket replaces its prior table + // entry. Ignore that entry only when detaching this owner would + // remove it; inherited peers keep the logical reservation live. + if caller_owns_binding && binding.owners.len() == 1 { continue; } - if endpoint.port == port - && udp_addr_conflicts(endpoint.addr, addr) - && !(endpoint.reuse_addr && reuse_addr) + if binding.port == port + && udp_addr_conflicts(binding.addr, addr) + && !(binding.reuse_addr && reuse_addr) { return false; } @@ -147,14 +240,23 @@ pub fn udp_register( if port == 0 { return Err(Errno::EINVAL); } + if udp_bindings().iter().any(|binding| { + binding.addr == addr + && binding.port == port + && binding.reuse_addr == reuse_addr + && binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx) + }) { + return Ok(()); + } if !udp_can_bind(pid, sock_idx, addr, port, reuse_addr) { return Err(Errno::EADDRINUSE); } - let endpoints = udp_endpoints(); - endpoints.retain(|endpoint| !(endpoint.pid == pid && endpoint.sock_idx == sock_idx)); - endpoints.push(UdpEndpoint { - pid, - sock_idx, + remove_binding_owner(udp_bindings(), pid, sock_idx); + udp_bindings().push(UdpBinding { + owners: alloc::vec![BindingOwner { pid, sock_idx }], addr, port, reuse_addr, @@ -163,28 +265,168 @@ pub fn udp_register( } pub fn udp_unregister(pid: u32, sock_idx: usize) { - udp_endpoints().retain(|endpoint| !(endpoint.pid == pid && endpoint.sock_idx == sock_idx)); + remove_binding_owner(udp_bindings(), pid, sock_idx); } pub fn udp_cleanup_process(pid: u32) { - udp_endpoints().retain(|endpoint| endpoint.pid != pid); + cleanup_binding_owner_pid(udp_bindings(), pid); } pub fn udp_lookup(dst_addr: [u8; 4], dst_port: u16) -> Vec { - udp_endpoints() + udp_bindings() .iter() - .copied() - .filter(|endpoint| endpoint.port == dst_port && udp_addr_matches(endpoint.addr, dst_addr)) + .filter(|binding| binding.port == dst_port && udp_addr_matches(binding.addr, dst_addr)) + .flat_map(|binding| { + binding.owners.iter().map(|owner| UdpEndpoint { + pid: owner.pid, + sock_idx: owner.sock_idx, + addr: binding.addr, + port: binding.port, + reuse_addr: binding.reuse_addr, + }) + }) .collect() } -/// One AF_INET TCP socket bound in the kernel-visible address table. +/// One AF_INET6 UDP endpoint bound in the in-kernel loopback network. #[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub struct TcpBinding { +pub struct Udp6Endpoint { pub pid: u32, pub sock_idx: usize, - pub addr: [u8; 4], + pub addr: [u8; 16], pub port: u16, + pub reuse_addr: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct Udp6Binding { + owners: Vec, + addr: [u8; 16], + port: u16, + reuse_addr: bool, +} + +impl HasBindingOwners for Udp6Binding { + fn owners(&self) -> &[BindingOwner] { + &self.owners + } + + fn owners_mut(&mut self) -> &mut Vec { + &mut self.owners + } +} + +struct Udp6EndpointTable(UnsafeCell>>); +unsafe impl Sync for Udp6EndpointTable {} + +static UDP6_ENDPOINTS: Udp6EndpointTable = Udp6EndpointTable(UnsafeCell::new(None)); + +fn udp6_bindings() -> &'static mut Vec { + let opt = unsafe { &mut *UDP6_ENDPOINTS.0.get() }; + opt.get_or_insert_with(Vec::new) +} + +fn udp6_addr_conflicts(a: [u8; 16], b: [u8; 16]) -> bool { + a == [0; 16] || b == [0; 16] || a == b +} + +fn udp6_addr_matches(bound: [u8; 16], dst: [u8; 16]) -> bool { + bound == [0; 16] || bound == dst +} + +pub fn udp6_can_bind( + pid: u32, + sock_idx: usize, + addr: [u8; 16], + port: u16, + reuse_addr: bool, +) -> bool { + udp6_bindings().iter().all(|binding| { + let caller_owns_binding = binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx); + (caller_owns_binding && binding.owners.len() == 1) + || binding.port != port + || !udp6_addr_conflicts(binding.addr, addr) + || (binding.reuse_addr && reuse_addr) + }) +} + +pub fn udp6_register( + pid: u32, + sock_idx: usize, + addr: [u8; 16], + port: u16, + reuse_addr: bool, +) -> Result<(), Errno> { + if port == 0 { + return Err(Errno::EINVAL); + } + if udp6_bindings().iter().any(|binding| { + binding.addr == addr + && binding.port == port + && binding.reuse_addr == reuse_addr + && binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx) + }) { + return Ok(()); + } + if !udp6_can_bind(pid, sock_idx, addr, port, reuse_addr) { + return Err(Errno::EADDRINUSE); + } + remove_binding_owner(udp6_bindings(), pid, sock_idx); + udp6_bindings().push(Udp6Binding { + owners: alloc::vec![BindingOwner { pid, sock_idx }], + addr, + port, + reuse_addr, + }); + Ok(()) +} + +pub fn udp6_unregister(pid: u32, sock_idx: usize) { + remove_binding_owner(udp6_bindings(), pid, sock_idx); +} + +pub fn udp6_cleanup_process(pid: u32) { + cleanup_binding_owner_pid(udp6_bindings(), pid); +} + +pub fn udp6_lookup(dst_addr: [u8; 16], dst_port: u16) -> Vec { + udp6_bindings() + .iter() + .filter(|binding| binding.port == dst_port && udp6_addr_matches(binding.addr, dst_addr)) + .flat_map(|binding| { + binding.owners.iter().map(|owner| Udp6Endpoint { + pid: owner.pid, + sock_idx: owner.sock_idx, + addr: binding.addr, + port: binding.port, + reuse_addr: binding.reuse_addr, + }) + }) + .collect() +} + +/// One AF_INET TCP socket bound in the kernel-visible address table. +#[derive(Clone, Debug, PartialEq, Eq)] +struct TcpBinding { + owners: Vec, + addr: [u8; 4], + port: u16, +} + +impl HasBindingOwners for TcpBinding { + fn owners(&self) -> &[BindingOwner] { + &self.owners + } + + fn owners_mut(&mut self) -> &mut Vec { + &mut self.owners + } } struct TcpBindingTable(UnsafeCell>>); @@ -203,7 +445,11 @@ fn tcp_addr_conflicts(a: [u8; 4], b: [u8; 4]) -> bool { pub fn tcp_can_bind(pid: u32, sock_idx: usize, addr: [u8; 4], port: u16) -> bool { for binding in tcp_bindings().iter() { - if binding.pid == pid && binding.sock_idx == sock_idx { + let caller_owns_binding = binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx); + if caller_owns_binding && binding.owners.len() == 1 { continue; } if binding.port == port && tcp_addr_conflicts(binding.addr, addr) { @@ -217,14 +463,22 @@ pub fn tcp_register(pid: u32, sock_idx: usize, addr: [u8; 4], port: u16) -> Resu if port == 0 { return Err(Errno::EINVAL); } + if tcp_bindings().iter().any(|binding| { + binding.addr == addr + && binding.port == port + && binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx) + }) { + return Ok(()); + } if !tcp_can_bind(pid, sock_idx, addr, port) { return Err(Errno::EADDRINUSE); } - let bindings = tcp_bindings(); - bindings.retain(|binding| !(binding.pid == pid && binding.sock_idx == sock_idx)); - bindings.push(TcpBinding { - pid, - sock_idx, + remove_binding_owner(tcp_bindings(), pid, sock_idx); + tcp_bindings().push(TcpBinding { + owners: alloc::vec![BindingOwner { pid, sock_idx }], addr, port, }); @@ -232,11 +486,118 @@ pub fn tcp_register(pid: u32, sock_idx: usize, addr: [u8; 4], port: u16) -> Resu } pub fn tcp_unregister(pid: u32, sock_idx: usize) { - tcp_bindings().retain(|binding| !(binding.pid == pid && binding.sock_idx == sock_idx)); + remove_binding_owner(tcp_bindings(), pid, sock_idx); } pub fn tcp_cleanup_process(pid: u32) { - tcp_bindings().retain(|binding| binding.pid != pid); + cleanup_binding_owner_pid(tcp_bindings(), pid); +} + +/// One AF_INET6 TCP socket bound in the kernel-visible address table. IPv6 +/// bindings are tracked separately from IPv4; a dual-stack wildcard also +/// reserves the IPv4 wildcard through `tcp_register`. +#[derive(Clone, Debug, PartialEq, Eq)] +struct Tcp6Binding { + owners: Vec, + addr: [u8; 16], + port: u16, +} + +impl HasBindingOwners for Tcp6Binding { + fn owners(&self) -> &[BindingOwner] { + &self.owners + } + + fn owners_mut(&mut self) -> &mut Vec { + &mut self.owners + } +} + +struct Tcp6BindingTable(UnsafeCell>>); +unsafe impl Sync for Tcp6BindingTable {} + +static TCP6_BINDINGS: Tcp6BindingTable = Tcp6BindingTable(UnsafeCell::new(None)); + +fn tcp6_bindings() -> &'static mut Vec { + let opt = unsafe { &mut *TCP6_BINDINGS.0.get() }; + opt.get_or_insert_with(Vec::new) +} + +fn tcp6_addr_conflicts(a: [u8; 16], b: [u8; 16]) -> bool { + a == [0; 16] || b == [0; 16] || a == b +} + +pub fn tcp6_can_bind(pid: u32, sock_idx: usize, addr: [u8; 16], port: u16) -> bool { + tcp6_bindings().iter().all(|binding| { + let caller_owns_binding = binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx); + (caller_owns_binding && binding.owners.len() == 1) + || binding.port != port + || !tcp6_addr_conflicts(binding.addr, addr) + }) +} + +pub fn tcp6_register( + pid: u32, + sock_idx: usize, + addr: [u8; 16], + port: u16, +) -> Result<(), Errno> { + if port == 0 { + return Err(Errno::EINVAL); + } + if tcp6_bindings().iter().any(|binding| { + binding.addr == addr + && binding.port == port + && binding + .owners + .iter() + .any(|owner| owner.pid == pid && owner.sock_idx == sock_idx) + }) { + return Ok(()); + } + if !tcp6_can_bind(pid, sock_idx, addr, port) { + return Err(Errno::EADDRINUSE); + } + remove_binding_owner(tcp6_bindings(), pid, sock_idx); + tcp6_bindings().push(Tcp6Binding { + owners: alloc::vec![BindingOwner { pid, sock_idx }], + addr, + port, + }); + Ok(()) +} + +pub fn tcp6_unregister(pid: u32, sock_idx: usize) { + remove_binding_owner(tcp6_bindings(), pid, sock_idx); +} + +pub fn tcp6_cleanup_process(pid: u32) { + cleanup_binding_owner_pid(tcp6_bindings(), pid); +} + +/// Add a fork/spawn child's process-local socket identity to every INET +/// binding owned by the corresponding parent socket. +/// +/// A dual-stack listener deliberately appears in both the IPv6 and IPv4 TCP +/// tables; walking all four tables preserves both halves as one inherited +/// socket. SO_REUSEADDR UDP bindings remain separate logical entries because +/// ownership is copied only from the exact parent `(pid, sock_idx)` identity. +pub fn inherit_inet_binding_owners(parent_pid: u32, child_pid: u32, sock_idx: usize) { + let parent = BindingOwner { + pid: parent_pid, + sock_idx, + }; + let child = BindingOwner { + pid: child_pid, + sock_idx, + }; + inherit_binding_owner(udp_bindings(), parent, child); + inherit_binding_owner(udp6_bindings(), parent, child); + inherit_binding_owner(tcp_bindings(), parent, child); + inherit_binding_owner(tcp6_bindings(), parent, child); } /// Per-socket kernel state. @@ -264,22 +625,35 @@ pub struct SocketInfo { pub host_net_handle: Option, /// Stored socket options as (level, optname, value) tuples. pub options: Vec<(u32, u32, u32)>, + /// SO_LINGER state. This is a structured option (`struct linger`), so it + /// is kept separately from integer-valued socket options. + pub linger_onoff: i32, + pub linger_seconds: i32, + /// SO_BINDTODEVICE binds a socket to a named virtual network interface. + pub bind_device: Option>, + /// TCP_CONGESTION algorithm name for this socket. Kandelo's virtual TCP + /// stack currently exposes the standard Linux default, "cubic". + pub tcp_congestion: Vec, /// Bound IPv4 address (for AF_INET sockets). pub bind_addr: [u8; 4], + /// Bound IPv6 address (for AF_INET6 sockets). + pub bind_addr6: [u8; 16], /// Bound port (for AF_INET sockets). pub bind_port: u16, /// Peer IPv4 address (for connected AF_INET sockets). pub peer_addr: [u8; 4], + /// Peer IPv6 address (for connected AF_INET6 sockets). + pub peer_addr6: [u8; 16], /// Peer port (for connected AF_INET sockets). pub peer_port: u16, - /// Pending connection socket indices (for listening sockets). - /// Used by AF_UNIX same-process sys_connect, which pre-allocates the - /// accepted SocketInfo and pushes its index here. + /// Legacy pending connection socket indices for manually constructed or + /// pre-shared-queue listeners. Normal AF_UNIX, AF_INET, and AF_INET6 + /// stream listeners use `shared_backlog_idx` instead. pub listen_backlog: Vec, - /// Index into the global SHARED_LISTENER_BACKLOG_TABLE for AF_INET - /// listening sockets. Set by sys_listen for INET sockets so all - /// fork-inherited copies of the listener share a single accept queue. - /// `None` for AF_UNIX or before listen() is called. + /// Index into the global SHARED_LISTENER_BACKLOG_TABLE for stream + /// listeners. Set by sys_listen so fork/spawn-inherited AF_UNIX, AF_INET, + /// and AF_INET6 listener copies share one accept queue. `None` before + /// listen() is called. pub shared_backlog_idx: Option, /// Host-visible wake token for listener readiness. Assigned by listen() /// and cloned across fork/spawn so every inherited listener fd waits on @@ -287,6 +661,11 @@ pub struct SocketInfo { pub accept_wake_idx: Option, /// Received UDP datagrams (for DGRAM sockets). pub dgram_queue: Vec, + /// Joined IPv4 multicast groups and source filters. + pub ipv4_multicast_memberships: Vec, + /// Received netlink datagrams. Netlink sockets are datagram-like and are + /// used by musl for route/interface enumeration. + pub netlink_queue: Vec>, /// Whether recv/send pipe indices refer to the global pipe table. Kept in /// serialized state for compatibility; runtime socket buffers are global. pub global_pipes: bool, @@ -318,14 +697,22 @@ impl SocketInfo { shut_wr: false, host_net_handle: None, options: Vec::new(), + linger_onoff: 0, + linger_seconds: 0, + bind_device: None, + tcp_congestion: b"cubic".to_vec(), bind_addr: [0; 4], + bind_addr6: [0; 16], bind_port: 0, peer_addr: [0; 4], + peer_addr6: [0; 16], peer_port: 0, listen_backlog: Vec::new(), shared_backlog_idx: None, accept_wake_idx: None, dgram_queue: Vec::new(), + ipv4_multicast_memberships: Vec::new(), + netlink_queue: Vec::new(), global_pipes: true, oob_byte: None, recv_timeout_us: 0, @@ -366,13 +753,14 @@ impl SocketInfo { /// /// Discarded in the child: /// * `dgram_queue` — buffered UDP datagrams. +/// * `netlink_queue` — buffered netlink datagrams. /// * `oob_byte` — pending TCP out-of-band byte. -/// * `listen_backlog` — pre-accepted AF_UNIX same-process connections. +/// * `listen_backlog` — legacy pre-accepted connections. /// Indices reference other entries in this process's SocketTable; if /// both parent and child kept them, both could `accept()` the same -/// pending connection. After fork/spawn, the parent retains them; -/// child gets fresh state. New connections that arrive post-fork are -/// added to whichever process the connecting peer wires up to. +/// pending connection. Normal stream listeners use the shared backlog; +/// this inline fallback remains only with the parent. +/// * `connect_error` — cached host-delegated connect failure state. /// /// Everything else is value-cloned. `host_net_handle` and /// `shared_backlog_idx` are still inherited; the cross-process refcount @@ -391,20 +779,28 @@ impl Clone for SocketInfo { shut_wr: self.shut_wr, host_net_handle: self.host_net_handle, options: self.options.clone(), + linger_onoff: self.linger_onoff, + linger_seconds: self.linger_seconds, + bind_device: self.bind_device.clone(), + tcp_congestion: self.tcp_congestion.clone(), bind_addr: self.bind_addr, + bind_addr6: self.bind_addr6, bind_port: self.bind_port, peer_addr: self.peer_addr, + peer_addr6: self.peer_addr6, peer_port: self.peer_port, listen_backlog: Vec::new(), // consume-once: don't double-accept shared_backlog_idx: self.shared_backlog_idx, accept_wake_idx: self.accept_wake_idx, dgram_queue: Vec::new(), // consume-once: don't double-deliver + ipv4_multicast_memberships: self.ipv4_multicast_memberships.clone(), + netlink_queue: Vec::new(), // consume-once: don't double-deliver global_pipes: self.global_pipes, oob_byte: None, // consume-once: don't double-deliver recv_timeout_us: self.recv_timeout_us, send_timeout_us: self.send_timeout_us, bind_path: self.bind_path.clone(), - connect_error: 0, // fork transitions Connecting → Closed; no error to inherit + connect_error: 0, // don't inherit a cached host-delegated failure } } } @@ -473,18 +869,25 @@ impl SocketTable { // accept queue across parent and children — any process can accept a // pending connection. Our SocketInfo lives in per-process tables, so a // naive fork+accept model would give each process its own backlog. To -// match POSIX semantics for AF_INET listeners (the typical fork-server -// pattern: nginx master + workers), we keep the actual pending queue +// match POSIX semantics for AF_INET, AF_INET6, and AF_UNIX listeners (the +// typical pre-fork server pattern), we keep the actual pending queue // in this global table and reference it by index from each forked // SocketInfo copy. -// -// AF_UNIX same-process listeners still use the inline `listen_backlog` -// field (sys_connect pre-allocates the accepted SocketInfo there). -/// A pending TCP connection waiting in a shared accept queue. +/// A pending stream connection waiting in a shared accept queue. pub struct PendingConnection { pub peer_addr: [u8; 4], + pub peer_addr6: [u8; 16], + /// True when `peer_addr6` is a native IPv6 source. For an IPv4 peer + /// accepted by a dual-stack IPv6 listener this is false and `peer_addr` + /// is converted to an IPv4-mapped address at accept time. + pub peer_is_ipv6: bool, pub peer_port: u16, + /// Process-local peer identity, used only when the accepting process is + /// also the AF_UNIX client owner. Pipe identity is revalidated before it + /// is installed so a recycled socket slot cannot receive stale OOB data. + pub peer_pid: u32, + pub peer_sock_idx: Option, /// Recv pipe index (in the global pipe table). Host writes incoming /// TCP data here; the accepting process reads from it. pub recv_pipe_idx: usize, @@ -545,20 +948,35 @@ impl SharedBacklogTable { } } - /// Decrement the reference count. If it reaches zero, free the slot - /// (queue is dropped — pending connections are lost, matching what - /// happens when the last process holding a listener fd closes it). + /// Decrement the reference count. If it reaches zero, free the slot and + /// close the server endpoints owned by queued, not-yet-accepted + /// connections. pub fn dec_ref(&mut self, idx: usize) { + let mut abandoned = Vec::new(); if let Some(entry) = self.entries.get_mut(idx) { if !entry.in_use { return; } entry.ref_count = entry.ref_count.saturating_sub(1); if entry.ref_count == 0 { - entry.queue.clear(); + abandoned = core::mem::take(&mut entry.queue); entry.in_use = false; } } + if abandoned.is_empty() { + return; + } + let pipes = unsafe { crate::pipe::global_pipe_table() }; + for pending in abandoned { + if let Some(pipe) = pipes.get_mut(pending.recv_pipe_idx) { + pipe.close_read_end(); + } + pipes.free_if_closed(pending.recv_pipe_idx); + if let Some(pipe) = pipes.get_mut(pending.send_pipe_idx) { + pipe.close_write_end(); + } + pipes.free_if_closed(pending.send_pipe_idx); + } } /// Push a pending connection. Returns true on success. @@ -648,4 +1066,155 @@ mod tests { sock.state = SocketState::Connected; assert_eq!(sock.state, SocketState::Connected); } + + #[test] + fn inherited_inet_bindings_keep_every_owner_until_final_close() { + const PARENT: u32 = 910_001; + const CHILD: u32 = 910_002; + const CONTENDER: u32 = 910_003; + const UDP4_IDX: usize = 41; + const UDP6_IDX: usize = 42; + const DUAL_STACK_TCP_IDX: usize = 43; + const UDP4_PORT: u16 = 64_901; + const UDP6_PORT: u16 = 64_902; + const TCP_PORT: u16 = 64_903; + const LOOPBACK6: [u8; 16] = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + + for pid in [PARENT, CHILD, CONTENDER] { + udp_cleanup_process(pid); + udp6_cleanup_process(pid); + tcp_cleanup_process(pid); + tcp6_cleanup_process(pid); + } + + udp_register(PARENT, UDP4_IDX, [127, 0, 0, 1], UDP4_PORT, false).unwrap(); + udp6_register(PARENT, UDP6_IDX, LOOPBACK6, UDP6_PORT, false).unwrap(); + // A dual-stack wildcard listener reserves the same socket identity in + // both protocol-family tables. + tcp6_register(PARENT, DUAL_STACK_TCP_IDX, [0; 16], TCP_PORT).unwrap(); + tcp_register( + PARENT, + DUAL_STACK_TCP_IDX, + [0, 0, 0, 0], + TCP_PORT, + ) + .unwrap(); + + inherit_inet_binding_owners(PARENT, CHILD, UDP4_IDX); + inherit_inet_binding_owners(PARENT, CHILD, UDP6_IDX); + inherit_inet_binding_owners(PARENT, CHILD, DUAL_STACK_TCP_IDX); + + let udp4_targets = udp_lookup([127, 0, 0, 1], UDP4_PORT); + assert!(udp4_targets + .iter() + .any(|target| target.pid == PARENT && target.sock_idx == UDP4_IDX)); + assert!(udp4_targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == UDP4_IDX)); + let udp6_targets = udp6_lookup(LOOPBACK6, UDP6_PORT); + assert!(udp6_targets + .iter() + .any(|target| target.pid == PARENT && target.sock_idx == UDP6_IDX)); + assert!(udp6_targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == UDP6_IDX)); + + // Parent close removes only the parent's process-local identity. + udp_unregister(PARENT, UDP4_IDX); + udp6_unregister(PARENT, UDP6_IDX); + tcp_unregister(PARENT, DUAL_STACK_TCP_IDX); + tcp6_unregister(PARENT, DUAL_STACK_TCP_IDX); + + let udp4_targets = udp_lookup([127, 0, 0, 1], UDP4_PORT); + assert_eq!(udp4_targets.len(), 1); + assert_eq!((udp4_targets[0].pid, udp4_targets[0].sock_idx), (CHILD, UDP4_IDX)); + let udp6_targets = udp6_lookup(LOOPBACK6, UDP6_PORT); + assert_eq!(udp6_targets.len(), 1); + assert_eq!((udp6_targets[0].pid, udp6_targets[0].sock_idx), (CHILD, UDP6_IDX)); + assert!(!udp_can_bind( + CONTENDER, + 1, + [127, 0, 0, 1], + UDP4_PORT, + false + )); + assert!(!udp6_can_bind( + CONTENDER, + 2, + LOOPBACK6, + UDP6_PORT, + false + )); + assert!(!tcp_can_bind( + CONTENDER, + 3, + [0, 0, 0, 0], + TCP_PORT + )); + assert!(!tcp6_can_bind(CONTENDER, 3, [0; 16], TCP_PORT)); + + // Final close drops each logical reservation. + udp_unregister(CHILD, UDP4_IDX); + udp6_unregister(CHILD, UDP6_IDX); + tcp_unregister(CHILD, DUAL_STACK_TCP_IDX); + tcp6_unregister(CHILD, DUAL_STACK_TCP_IDX); + assert!(udp_lookup([127, 0, 0, 1], UDP4_PORT).is_empty()); + assert!(udp6_lookup(LOOPBACK6, UDP6_PORT).is_empty()); + assert!(udp_can_bind( + CONTENDER, + 1, + [127, 0, 0, 1], + UDP4_PORT, + false + )); + assert!(udp6_can_bind( + CONTENDER, + 2, + LOOPBACK6, + UDP6_PORT, + false + )); + assert!(tcp_can_bind( + CONTENDER, + 3, + [0, 0, 0, 0], + TCP_PORT + )); + assert!(tcp6_can_bind(CONTENDER, 3, [0; 16], TCP_PORT)); + } + + #[test] + fn inherited_udp_owner_does_not_merge_reuseaddr_bindings() { + const PARENT: u32 = 920_001; + const CHILD: u32 = 920_002; + const FIRST_IDX: usize = 51; + const SECOND_IDX: usize = 52; + const PORT: u16 = 64_904; + + udp_cleanup_process(PARENT); + udp_cleanup_process(CHILD); + udp_register(PARENT, FIRST_IDX, [0, 0, 0, 0], PORT, true).unwrap(); + udp_register(PARENT, SECOND_IDX, [0, 0, 0, 0], PORT, true).unwrap(); + + inherit_inet_binding_owners(PARENT, CHILD, FIRST_IDX); + let targets = udp_lookup([127, 0, 0, 1], PORT); + assert_eq!(targets.len(), 3); + assert!(targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == FIRST_IDX)); + assert!(!targets + .iter() + .any(|target| target.pid == CHILD && target.sock_idx == SECOND_IDX)); + + udp_unregister(PARENT, FIRST_IDX); + udp_unregister(CHILD, FIRST_IDX); + let targets = udp_lookup([127, 0, 0, 1], PORT); + assert_eq!(targets.len(), 1); + assert_eq!( + (targets[0].pid, targets[0].sock_idx), + (PARENT, SECOND_IDX) + ); + + udp_unregister(PARENT, SECOND_IDX); + } } diff --git a/crates/kernel/src/syscalls.rs b/crates/kernel/src/syscalls.rs index 175960c9aa..1057d45874 100644 --- a/crates/kernel/src/syscalls.rs +++ b/crates/kernel/src/syscalls.rs @@ -1,5 +1,6 @@ extern crate alloc; +use alloc::collections::VecDeque; use alloc::vec::Vec; use wasm_posix_shared::Errno; use wasm_posix_shared::access::{R_OK, W_OK, X_OK}; @@ -8,7 +9,8 @@ use wasm_posix_shared::fd_flags::{FD_CLOEXEC, FD_CLOFORK}; use wasm_posix_shared::flags::*; use wasm_posix_shared::flock_op::*; use wasm_posix_shared::lock_type::*; -use wasm_posix_shared::mode::{S_IFCHR, S_IFDIR, S_IFIFO, S_IFMT, S_IFREG}; +use wasm_posix_shared::mode::{S_IFCHR, S_IFDIR, S_IFIFO, S_IFLNK, S_IFMT, S_IFREG}; +use wasm_posix_shared::rlimit::{RLIM_INFINITY, RLIMIT_FSIZE}; use wasm_posix_shared::seek::*; use wasm_posix_shared::{WasmFlock, WasmPollFd, WasmStat, WasmStatfs, WasmTimespec}; @@ -20,7 +22,7 @@ use crate::process::{HostIO, Process}; use crate::signal::SignalHandler; use wasm_posix_shared::mmap::{MAP_ANONYMOUS, MAP_FAILED}; use wasm_posix_shared::signal::{ - NSIG, SIG_BLOCK, SIG_DFL, SIG_IGN, SIG_SETMASK, SIG_UNBLOCK, SIGKILL, SIGSTOP, + NSIG, SIG_BLOCK, SIG_DFL, SIG_IGN, SIG_SETMASK, SIG_UNBLOCK, SIGKILL, SIGSTOP, SIGXFSZ, }; /// Creation flags that are stripped from status_flags after open. @@ -146,23 +148,14 @@ fn fallback_lock_table(_proc: &mut Process) -> &mut LockTable { unsafe { crate::lock::global_fallback_lock_table() } } -/// Mozilla CA root bundle, vendored from . -/// Served at `/etc/ssl/cert.pem` so OpenSSL's `SSL_CTX_set_default_verify_paths` -/// (which the wasm sysroot was built with `--openssldir=/etc/ssl`) finds a -/// trust store without depending on the host filesystem. ~220 KB, refreshed -/// manually via `packages/registry/openssl/fetch-cacert.sh`. -const CACERT_PEM: &[u8] = include_bytes!("../../../packages/registry/openssl/cacert.pem"); - -/// Return static content for synthetic files that are not owned by rootfs.vfs. +/// Return content for dynamic files that cannot be owned by rootfs.vfs. /// -/// Keep NSS-style files (`/etc/passwd`, `/etc/group`, `/etc/hosts`, etc.) in -/// rootfs.vfs so the mounted image remains the source of truth. The vendored -/// CA bundle stays synthetic because OpenSSL's default path needs to be present -/// even for minimal VFS images that do not carry a full `/etc` tree. +/// Static `/etc` policy and data, including OpenSSL configuration and trust +/// roots, belong to the mounted image. `/etc/mtab` is the exception because it +/// reports the running kernel's mount state rather than immutable image data. fn synthetic_file_content(path: &[u8]) -> Option<&'static [u8]> { match path { b"/etc/mtab" => Some(crate::procfs::MOUNTS_CONTENT), - b"/etc/ssl/cert.pem" => Some(CACERT_PEM), _ => None, } } @@ -251,21 +244,24 @@ pub(crate) fn maybe_release_fb0(pid: u32) { ); } +/// True iff `proc` still has an open fd referencing `device`. +/// +/// Iterate the authoritative fd table rather than assuming the default +/// 1024-descriptor limit: `RLIMIT_NOFILE` and `F_DUPFD` can place a surviving +/// alias at a much higher number. +fn proc_has_virtual_device_fd(proc: &Process, device: VirtualDevice) -> bool { + use crate::ofd::FileType; + proc.fd_table.iter().any(|(_, entry)| { + proc.ofd_table.get(entry.ofd_ref.0).is_some_and(|ofd| { + ofd.file_type == FileType::CharDevice + && VirtualDevice::from_host_handle(ofd.host_handle) == Some(device) + }) + }) +} + /// True iff `proc` still has an open fd referencing `/dev/fb0`. fn proc_has_fb0_fd(proc: &Process) -> bool { - use crate::ofd::FileType; - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Fb0) - { - return true; - } - } - } - } - false + proc_has_virtual_device_fd(proc, VirtualDevice::Fb0) } /// Try to claim `/dev/input/mice` for the calling process. @@ -304,19 +300,7 @@ pub(crate) fn maybe_release_mice(pid: u32) { /// True iff `proc` still has an open fd referencing `/dev/input/mice`. fn proc_has_mice_fd(proc: &Process) -> bool { - use crate::ofd::FileType; - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Mice) - { - return true; - } - } - } - } - false + proc_has_virtual_device_fd(proc, VirtualDevice::Mice) } /// Try to claim `/dev/dsp` for the calling process. @@ -355,19 +339,7 @@ pub(crate) fn maybe_release_dsp(pid: u32) { /// True iff `proc` still has an open fd referencing `/dev/dsp`. fn proc_has_dsp_fd(proc: &Process) -> bool { - use crate::ofd::FileType; - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::CharDevice - && VirtualDevice::from_host_handle(ofd.host_handle) == Some(VirtualDevice::Dsp) - { - return true; - } - } - } - } - false + proc_has_virtual_device_fd(proc, VirtualDevice::Dsp) } /// Handle ioctl on `/dev/dsp`. @@ -680,25 +652,101 @@ fn release_process_dri_mappings(proc: &mut Process, host: &mut dyn HostIO) { } pub(crate) fn release_exec_image_state(proc: &mut Process, host: &mut dyn HostIO) { - // /dev/fb0 cleanup: exec wipes the binding. Tell the host before - // its memory snapshot of the process is invalidated, then drop the - // global ownership claim so the new image can re-acquire if it - // needs the device. + // /dev/fb0 cleanup: exec wipes the address-space binding. Tell the host + // before its memory snapshot is invalidated, but retain device ownership + // while a non-CLOEXEC fb fd remains open in this same process. if proc.fb_binding.is_some() { host.unbind_framebuffer(proc.pid as i32); proc.fb_binding = None; - maybe_release_fb0(proc.pid); + if !proc_has_fb0_fd(proc) { + maybe_release_fb0(proc.pid); + } } - // /dev/input/mice cleanup: exec also drops mouse ownership. The - // post-exec image starts with a clean queue — no stale packets from - // the parent program survive across exec. - maybe_release_mice(proc.pid); - // /dev/dsp cleanup: same — drop ownership and flush any queued PCM - // so a post-exec program doesn't hear the tail of its predecessor. - maybe_release_dsp(proc.pid); + // Mouse and DSP state belongs to their surviving open descriptions, not + // the discarded Wasm image. A CLOEXEC close (or the eventual last close) + // releases ownership and drains the corresponding queue. release_process_dri_mappings(proc, host); } +/// Commit the exec-defined state transition without cloning descriptor-backed +/// kernel objects through a wire format. +pub(crate) fn commit_exec_state( + proc: &mut Process, + host: &mut dyn HostIO, + caller_tid: u32, +) -> Result<(), Errno> { + if matches!( + proc.state, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo + ) { + return Err(Errno::ESRCH); + } + // Exec replaces the image, not the process's job-control state. A stop + // can be generated while the host is asynchronously resolving the new + // executable, so retain it through the irreversible commit point. + let lifecycle_state = proc.state; + if caller_tid != 0 && caller_tid != proc.pid { + let thread_index = proc + .threads + .iter() + .position(|thread| thread.tid == caller_tid) + .ok_or(Errno::ESRCH)?; + let caller = proc.threads.remove(thread_index); + proc.signals.blocked = caller.signals.blocked; + proc.main_thread_signals = caller.signals; + } + + let cloexec_fds: Vec = proc + .fd_table + .iter() + .filter(|(_, entry)| entry.fd_flags & FD_CLOEXEC != 0) + .map(|(fd, _)| fd) + .collect(); + for fd in cloexec_fds { + let _ = sys_close(proc, host, fd); + } + for stream in proc.dir_streams.iter_mut().filter_map(Option::take) { + let _ = host.host_closedir(stream.host_handle); + } + + release_exec_image_state(proc, host); + proc.signals.reset_dispositions_for_exec(); + + // Kernel-backed process-shared primitives outlive the process address + // space, but this image's participation in them does not. Drop stale + // mutex ownership and cond/barrier waiter entries before the memory and + // sibling-thread state that could complete those operations disappears. + unsafe { crate::pshared::global_pshared_table() }.cleanup_process(proc.pid); + + // Reset state owned by the discarded address space or its threads. Open + // descriptions and their backing tables, terminal queues, CWD, IDs, + // rlimits, locks, and alarm/ITIMER_REAL state remain in place. + proc.memory = crate::memory::MemoryManager::new(); + proc.state = lifecycle_state; + proc.exit_status = 0; + proc.exit_signal = 0; + proc.thread_name = [0; 16]; + proc.threads.clear(); + proc.next_tid = 0; + proc.sigsuspend_saved_mask = None; + proc.alt_stack_sp = 0; + proc.alt_stack_flags = 2; // SS_DISABLE + proc.alt_stack_size = 0; + proc.alt_stack_depth = 0; + for timer_id in 0..proc.posix_timers.len() as u32 { + proc.remove_posix_timer_notification(timer_id); + } + proc.posix_timers.clear(); + proc.fork_child = false; + proc.fork_exec_path = None; + proc.fork_exec_argv = None; + proc.fork_fd_actions.clear(); + proc.fork_pipe_replay.clear(); + proc.fork_count = 0; + proc.has_exec = true; + Ok(()) +} + /// Release a per-fd handle (DESTROY_DUMB / GEM_CLOSE): drops the /// handle from the fd's namespace, decrefs the bo, and if the /// refcount hits zero asks the host to free the backing. @@ -1668,6 +1716,414 @@ fn check_access_for_ids(uid: u32, gid: u32, st: &WasmStat, amode: u32) -> Result } } +/// `PATH_MAX` includes the terminating NUL; caller pathnames and bounded +/// symlink substitutions may therefore contain at most 4095 bytes. Internal +/// canonical paths may be longer when resolving a short relative pathname +/// from a deep CWD. Component limits are byte limits as required by the guest +/// ABI, not JavaScript UTF-16 code-unit limits in a host backend. +const NAMESPACE_PATH_MAX: usize = 4096; +const NAMESPACE_NAME_MAX: usize = 255; + +#[derive(Clone, Copy)] +struct PathResolveOptions { + follow_final_symlink: bool, + allow_missing_final: bool, + allow_missing_directory: bool, + use_real_ids: bool, +} + +impl PathResolveOptions { + const FOLLOW: Self = Self { + follow_final_symlink: true, + allow_missing_final: false, + allow_missing_directory: false, + use_real_ids: false, + }; + + const NOFOLLOW: Self = Self { + follow_final_symlink: false, + allow_missing_final: false, + allow_missing_directory: false, + use_real_ids: false, + }; + + const CREATE_ENTRY: Self = Self { + follow_final_symlink: false, + allow_missing_final: true, + allow_missing_directory: false, + use_real_ids: false, + }; + + const CREATE_DIRECTORY: Self = Self { + follow_final_symlink: false, + allow_missing_final: true, + allow_missing_directory: true, + use_real_ids: false, + }; +} + +#[derive(Debug)] +struct ResolvedNamespacePath { + path: Vec, + stat: Option, +} + +fn dev_fd_path_stat(proc: &Process) -> WasmStat { + WasmStat { + st_dev: 5, + st_ino: 0, + st_mode: S_IFCHR | 0o666, + st_nlink: 1, + st_uid: proc.euid, + st_gid: proc.egid, + st_size: 0, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + } +} + +fn is_procfs_namespace_path(path: &[u8]) -> bool { + path == b"/proc" || path.starts_with(b"/proc/") +} + +fn is_devfs_namespace_path(path: &[u8]) -> bool { + path == b"/dev" || path.starts_with(b"/dev/") +} + +fn is_host_backed_devfs_path(path: &[u8]) -> bool { + path == b"/dev/shm" || path.starts_with(b"/dev/shm/") +} + +/// Inspect one canonical namespace path without following its final symlink. +/// Parent components have already been resolved by `resolve_namespace_path_from`. +fn namespace_lstat_raw( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], +) -> Result { + if let Some(entry) = crate::procfs::match_procfs(path, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; + return Ok(crate::procfs::procfs_stat(&entry, 0, false)); + } + // `/proc` is wholly kernel-owned. Unknown names must not fall through to + // a rootfs backend where they could expose or mutate a hidden inode. + if is_procfs_namespace_path(path) { + return Err(Errno::ENOENT); + } + // `/dev/shm` is a higher-priority writable host mount on both Node and + // browser. Its root metadata must come from that mount rather than the + // synthetic devfs directory fallback. + if path == b"/dev/shm" { + match host.host_lstat(path) { + Ok(stat) if stat.st_mode & S_IFMT == S_IFDIR => return Ok(stat), + Ok(_) | Err(Errno::ENOENT) => {} + Err(error) => return Err(error), + } + } + if let Some(st) = crate::devfs::match_devfs_stat(path, proc.euid, proc.egid) { + return Ok(st); + } + if let Some(dev) = match_virtual_device(path) { + return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); + } + if let Some(st) = match_pty_stat(path, proc.euid, proc.egid) { + return Ok(st); + } + if match_dev_fd(path).is_some() { + return Ok(dev_fd_path_stat(proc)); + } + if let Some(st) = synthetic_file_stat(path, proc.euid, proc.egid) { + return Ok(st); + } + // Like procfs, kernel devfs owns its namespace. `/dev/shm` is the one + // explicit host-backed subtree; unknown names elsewhere must not reveal a + // hidden rootfs entry. + if is_devfs_namespace_path(path) && !is_host_backed_devfs_path(path) { + return Err(Errno::ENOENT); + } + host.host_lstat(path) +} + +fn namespace_readlink_raw( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], +) -> Result, Errno> { + let mut target = alloc::vec![0u8; NAMESPACE_PATH_MAX]; + let len = if let Some(entry) = crate::procfs::match_procfs(path, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; + if !entry.is_symlink() { + return Err(Errno::EINVAL); + } + crate::procfs::procfs_readlink(proc, &entry, &mut target)? + } else { + host.host_readlink(path, &mut target)? + }; + if len >= NAMESPACE_PATH_MAX { + return Err(Errno::ENAMETOOLONG); + } + target.truncate(len); + if target.is_empty() { + return Err(Errno::ENOENT); + } + Ok(target) +} + +fn append_path_component(path: &mut Vec, component: &[u8]) { + if path.len() > 1 { + path.push(b'/'); + } + path.extend_from_slice(component); +} + +fn pop_path_component(path: &mut Vec) { + if path.len() <= 1 { + path.clear(); + path.push(b'/'); + return; + } + if let Some(pos) = path.iter().rposition(|&byte| byte == b'/') { + path.truncate(pos.max(1)); + } +} + +fn substituted_path_len(target: &[u8], pending: &VecDeque>) -> Option { + let mut len = target.len(); + let mut needs_separator = target.last() != Some(&b'/'); + for component in pending { + if needs_separator { + len = len.checked_add(1)?; + } + len = len.checked_add(component.len())?; + needs_separator = true; + } + Some(len) +} + +/// Resolve a pathname through Kandelo's global namespace one component at a +/// time. Backends only receive canonical candidates, so `..` can cross mount +/// roots and symlink targets can cross mounts without being trapped in the +/// backend selected from the input spelling. +fn resolve_namespace_path_from( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], + base: &[u8], + options: PathResolveOptions, +) -> Result { + if path.is_empty() { + return Err(Errno::ENOENT); + } + if path.len() >= NAMESPACE_PATH_MAX { + return Err(Errno::ENAMETOOLONG); + } + + let absolute = crate::path::make_absolute(path, base); + if absolute.first() != Some(&b'/') { + return Err(Errno::ENOENT); + } + + let mut require_directory = absolute.len() > 1 && absolute.last() == Some(&b'/'); + let mut pending: VecDeque> = absolute + .split(|&byte| byte == b'/') + .filter(|component| !component.is_empty()) + .map(|component| component.to_vec()) + .collect(); + if pending + .iter() + .any(|component| component.len() > NAMESPACE_NAME_MAX) + { + return Err(Errno::ENAMETOOLONG); + } + if pending.back().map(Vec::as_slice) == Some(b".") + || pending.back().map(Vec::as_slice) == Some(b"..") + { + require_directory = true; + } + + let root_stat = namespace_lstat_raw(proc, host, b"/")?; + if root_stat.st_mode & S_IFMT != S_IFDIR { + return Err(Errno::ENOTDIR); + } + let (search_uid, search_gid) = if options.use_real_ids { + (proc.uid, proc.gid) + } else { + (proc.euid, proc.egid) + }; + check_access_for_ids(search_uid, search_gid, &root_stat, X_OK)?; + + let mut resolved = alloc::vec![b'/']; + let mut final_stat = Some(root_stat); + let mut symlink_count = 0u32; + + while let Some(component) = pending.pop_front() { + if component == b"." { + continue; + } + if component == b".." { + pop_path_component(&mut resolved); + let stat = namespace_lstat_raw(proc, host, &resolved)?; + if !pending.is_empty() { + if stat.st_mode & S_IFMT != S_IFDIR { + return Err(Errno::ENOTDIR); + } + check_access_for_ids(search_uid, search_gid, &stat, X_OK)?; + } + final_stat = Some(stat); + continue; + } + + let is_final = pending.is_empty(); + let mut candidate = resolved.clone(); + append_path_component(&mut candidate, &component); + let stat = match namespace_lstat_raw(proc, host, &candidate) { + Ok(stat) => stat, + Err(Errno::ENOENT) if is_final && options.allow_missing_final => { + if is_procfs_namespace_path(&candidate) + || (is_devfs_namespace_path(&candidate) + && !is_host_backed_devfs_path(&candidate)) + { + return Err(Errno::EROFS); + } + if require_directory && !options.allow_missing_directory { + return Err(Errno::ENOENT); + } + return Ok(ResolvedNamespacePath { + path: candidate, + stat: None, + }); + } + Err(error) => return Err(error), + }; + + let follow_symlink = stat.st_mode & S_IFMT == S_IFLNK + && (!is_final || options.follow_final_symlink || require_directory); + if follow_symlink { + symlink_count += 1; + if symlink_count > 40 { + return Err(Errno::ELOOP); + } + let procfs_entry = crate::procfs::match_procfs(&candidate, proc.pid); + // Kandelo currently models thread-self process files through the + // process-level procfs entries rather than a separate + // `/proc//task/` tree. Resolve the link to that truthful + // implemented identity while readlink(2) continues to expose the + // Linux-compatible textual target. + if matches!( + procfs_entry, + Some(crate::procfs::ProcfsEntry::ThreadSelfLink) + ) { + resolved = alloc::format!("/proc/{}", proc.pid).into_bytes(); + final_stat = Some(namespace_lstat_raw(proc, host, &resolved)?); + continue; + } + // `/proc//fd/N` is a Linux-style magic link whose OFD may not + // have a traversable pathname at all (pipe, socket, memfd, ...). + // Preserve final fd-link metadata for procfs handling instead of + // feeding its descriptive OFD path to a filesystem backend. + // Directory-fd traversal through procfs remains a documented gap. + if matches!( + procfs_entry, + Some(crate::procfs::ProcfsEntry::FdLink(_, _)) + ) { + if !is_final || require_directory { + return Err(Errno::ENOTDIR); + } + resolved = candidate; + final_stat = Some(stat); + continue; + } + let target = namespace_readlink_raw(proc, host, &candidate)?; + if substituted_path_len(&target, &pending) + .ok_or(Errno::ENAMETOOLONG)? + >= NAMESPACE_PATH_MAX + { + return Err(Errno::ENAMETOOLONG); + } + let had_remainder = !pending.is_empty(); + if !had_remainder && target.len() > 1 && target.last() == Some(&b'/') { + require_directory = true; + } + if target.first() == Some(&b'/') { + resolved.clear(); + resolved.push(b'/'); + final_stat = Some(root_stat); + } + let target_components: Vec> = target + .split(|&byte| byte == b'/') + .filter(|part| !part.is_empty()) + .map(|part| part.to_vec()) + .collect(); + if target_components + .iter() + .any(|component| component.len() > NAMESPACE_NAME_MAX) + { + return Err(Errno::ENAMETOOLONG); + } + for part in target_components.into_iter().rev() { + pending.push_front(part); + } + continue; + } + + if !is_final || require_directory { + if stat.st_mode & S_IFMT != S_IFDIR { + return Err(Errno::ENOTDIR); + } + check_access_for_ids(search_uid, search_gid, &stat, X_OK)?; + } + resolved = candidate; + final_stat = Some(stat); + } + + let stat = match final_stat { + Some(stat) if resolved == b"/" => stat, + _ => namespace_lstat_raw(proc, host, &resolved)?, + }; + if require_directory && stat.st_mode & S_IFMT != S_IFDIR { + return Err(Errno::ENOTDIR); + } + Ok(ResolvedNamespacePath { + path: resolved, + stat: Some(stat), + }) +} + +fn resolve_namespace_path( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], + options: PathResolveOptions, +) -> Result { + resolve_namespace_path_from(proc, host, path, &proc.cwd, options) +} + +/// Resolve an existing pathname to the canonical global namespace spelling. +/// Kept crate-visible for exported syscall wrappers that must retry an +/// operation after the ordinary syscall path releases its host borrow. +pub(crate) fn resolve_existing_namespace_path( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], +) -> Result, Errno> { + Ok(resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path) +} + +fn ensure_host_mutable_namespace_path(path: &[u8]) -> Result<(), Errno> { + if is_procfs_namespace_path(path) + || (is_devfs_namespace_path(path) && !is_host_backed_devfs_path(path)) + || synthetic_file_content(path).is_some() + { + return Err(Errno::EROFS); + } + Ok(()) +} + fn parent_path(path: &[u8]) -> Vec { if path == b"/" { return alloc::vec![b'/']; @@ -1679,7 +2135,7 @@ fn parent_path(path: &[u8]) -> Vec { } fn check_search_dir(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { - let st = host.host_stat(path)?; + let st = namespace_lstat_raw(proc, host, path)?; if st.st_mode & S_IFMT != S_IFDIR { return Err(Errno::ENOTDIR); } @@ -1709,18 +2165,18 @@ fn check_search_dir_chain(proc: &Process, host: &mut dyn HostIO, dir: &[u8]) -> fn check_parent_writable(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { let parent = parent_path(path); check_search_dir_chain(proc, host, &parent)?; - let st = host.host_stat(&parent)?; + let st = namespace_lstat_raw(proc, host, &parent)?; check_access(proc, &st, W_OK | X_OK) } fn check_sticky_child(proc: &Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { let parent = parent_path(path); - let parent_st = host.host_stat(&parent)?; + let parent_st = namespace_lstat_raw(proc, host, &parent)?; if parent_st.st_mode & S_ISVTX == 0 || proc.euid == 0 { return Ok(()); } - let child_st = host.host_lstat(path)?; + let child_st = namespace_lstat_raw(proc, host, path)?; if proc.euid == parent_st.st_uid || proc.euid == child_st.st_uid { Ok(()) } else { @@ -1802,7 +2258,29 @@ pub fn sys_open( } else { mode }; - let resolved = crate::path::resolve_path(path, &proc.cwd); + let exclusive_create = oflags & O_CREAT != 0 && oflags & O_EXCL != 0; + let resolve_options = PathResolveOptions { + // O_CREAT|O_EXCL must observe an existing final symlink itself and + // fail with EEXIST rather than following a dangling link and creating + // its target. + follow_final_symlink: oflags & O_NOFOLLOW == 0 && !exclusive_create, + allow_missing_final: oflags & O_CREAT != 0, + allow_missing_directory: false, + use_real_ids: false, + }; + let resolved_entry = resolve_namespace_path(proc, host, path, resolve_options)?; + if resolved_entry + .stat + .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFLNK) + { + if exclusive_create { + return Err(Errno::EEXIST); + } + if oflags & O_NOFOLLOW != 0 { + return Err(Errno::ELOOP); + } + } + let resolved = resolved_entry.path; // /dev/fd/N and /dev/stdin|stdout|stderr — dup an existing fd if let Some(target_fd) = match_dev_fd(&resolved) { @@ -1873,18 +2351,17 @@ pub fn sys_open( // /dev/tty — open controlling terminal (alias for current session's PTY or stdin) if resolved == b"/dev/tty" { // Check if any open fd refers to a PTY slave — use that - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i as i32) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::PtySlave { - // Dup this fd - proc.ofd_table.inc_ref(entry.ofd_ref.0); - let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(entry.ofd_ref, fd_flags)?; - return Ok(fd); - } - } - } + let pty_ofd = proc.fd_table.iter().find_map(|(_, entry)| { + proc.ofd_table + .get(entry.ofd_ref.0) + .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) + .then_some(entry.ofd_ref) + }); + if let Some(ofd_ref) = pty_ofd { + proc.ofd_table.inc_ref(ofd_ref.0); + let fd_flags = oflags_to_fd_flags(oflags); + let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; + return Ok(fd); } // Fallback: dup stdin (fd 0) as the controlling terminal if let Ok(entry) = proc.fd_table.get(0) { @@ -1903,7 +2380,9 @@ pub fn sys_open( } // Devfs (/dev, /dev/pts, etc.) — in-kernel directory listing - if crate::devfs::match_devfs_dir(&resolved).is_some() { + if !is_host_backed_devfs_path(&resolved) + && crate::devfs::match_devfs_dir(&resolved).is_some() + { return crate::devfs::devfs_open_dir(proc, resolved, oflags); } @@ -2046,17 +2525,37 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( } FileType::Socket => { let sock_idx = (-(host_handle + 1)) as usize; + let unix_dgram_send_state_changed = proc.sockets.get(sock_idx).is_some_and(|sock| { + sock.domain == crate::socket::SocketDomain::Unix + && sock.sock_type == crate::socket::SocketType::Dgram + }); if let Some(sock) = proc.sockets.get(sock_idx) { + if let Some(path) = sock.bind_path.as_deref() { + let registry = unsafe { + crate::unix_socket::global_unix_socket_registry() + }; + registry.remove_owner(path, proc.pid, sock_idx); + } if sock.domain == crate::socket::SocketDomain::Inet && sock.sock_type == crate::socket::SocketType::Dgram { crate::socket::udp_unregister(proc.pid, sock_idx); let _ = host.host_udp_unbind(sock_idx as i32); } - if sock.domain == crate::socket::SocketDomain::Inet - && sock.sock_type == crate::socket::SocketType::Stream + if sock.domain == crate::socket::SocketDomain::Inet6 + && sock.sock_type == crate::socket::SocketType::Dgram + { + crate::socket::udp6_unregister(proc.pid, sock_idx); + } + if matches!( + sock.domain, + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6 + ) && sock.sock_type == crate::socket::SocketType::Stream { crate::socket::tcp_unregister(proc.pid, sock_idx); + if sock.domain == crate::socket::SocketDomain::Inet6 { + crate::socket::tcp6_unregister(proc.pid, sock_idx); + } } // Connected AF_INET socket: drop one cross-process ref // (fork/spawn share host_net_handle by value). Only the @@ -2076,6 +2575,13 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( crate::socket::shared_listener_backlog_table().dec_ref(shared_idx) }; } + let orderly_tcp_close = matches!( + (sock.domain, sock.sock_type), + ( + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6, + crate::socket::SocketType::Stream, + ) + ); if let Some(send_idx) = sock.send_buf_idx { let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(send_idx) }; if let Some(pipe) = pipe { @@ -2086,19 +2592,38 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( if let Some(recv_idx) = sock.recv_buf_idx { let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(recv_idx) }; if let Some(pipe) = pipe { - pipe.close_read_end(); + if orderly_tcp_close { + pipe.close_read_end_orderly(); + } else { + pipe.close_read_end(); + } unsafe { crate::pipe::global_pipe_table().free_if_closed(recv_idx) }; } } } + // peer_idx is a process-local socket-table identity used by + // AF_UNIX datagrams and same-process stream OOB delivery. + // Clear references before freeing the slot so a later + // allocation cannot receive data intended for the closed + // endpoint. + for peer_idx in 0..proc.sockets.len() { + if let Some(peer) = proc.sockets.get_mut(peer_idx) { + if peer.peer_idx == Some(sock_idx) { + peer.peer_idx = None; + } + } + } proc.sockets.free(sock_idx); + // Closing either endpoint can invalidate a process-local + // AF_UNIX datagram association. Retry blocked sends and + // readiness waits so they observe ECONNREFUSED/EPERM instead + // of waiting until an unrelated timeout. + if unix_dgram_send_state_changed { + crate::wakeup::push_datagram_writable(); + } } FileType::EventFd => { - // Free the eventfd state - let efd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.eventfds.get_mut(efd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::Epoll => { let ep_idx = (-(host_handle + 1)) as usize; @@ -2107,22 +2632,13 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( } } FileType::TimerFd => { - let tfd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.timerfds.get_mut(tfd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::SignalFd => { - let sfd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.signalfds.get_mut(sfd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::MemFd => { - let memfd_idx = (-(host_handle + 1)) as usize; - if let Some(slot) = proc.memfds.get_mut(memfd_idx) { - *slot = None; - } + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } FileType::PtyMaster => { let pty_idx = host_handle as usize; @@ -2152,11 +2668,10 @@ pub fn sys_close(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result<( let _ = host.host_closedir(dir_host_handle); } // Procfs buffers: free the content buffer - if crate::procfs::is_procfs_buf_handle(host_handle) { - let buf_idx = crate::procfs::procfs_buf_idx(host_handle); - if let Some(slot) = proc.procfs_bufs.get_mut(buf_idx) { - *slot = None; - } + if file_type == FileType::Regular + && crate::procfs::is_procfs_buf_handle(host_handle) + { + crate::descriptor_backing::release_for_ofd(file_type, host_handle); } else if host_handle == crate::procfs::PROCFS_DIR_HANDLE || host_handle == crate::devfs::DEVFS_DIR_HANDLE { @@ -2314,24 +2829,16 @@ pub fn sys_read( let tfd_idx = (-(host_handle + 1)) as usize; // Compute expirations lazily let (now_sec, now_nsec) = host.host_clock_gettime(0)?; - if let Some(Some(tfd)) = proc.timerfds.get_mut(tfd_idx) { + let count = crate::descriptor_backing::with_timerfds(|table| { + let tfd = table.get_mut(tfd_idx).ok_or(Errno::EBADF)?; timerfd_compute_expirations(tfd, now_sec, now_nsec); - } - let tfd = proc - .timerfds - .get_mut(tfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - if tfd.expirations == 0 { - return Err(Errno::EAGAIN); - } - let tfd = proc - .timerfds - .get_mut(tfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let count = tfd.expirations; - tfd.expirations = 0; + if tfd.expirations == 0 { + return Err(Errno::EAGAIN); + } + let count = tfd.expirations; + tfd.expirations = 0; + Ok(count) + })?; buf[..8].copy_from_slice(&count.to_le_bytes()); Ok(8) } @@ -2342,39 +2849,42 @@ pub fn sys_read( return Err(Errno::EINVAL); } let sfd_idx = (-(host_handle + 1)) as usize; - let mask = proc - .signalfds - .get(sfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)? - .mask; + let mask = crate::descriptor_backing::with_signalfds(|table| { + table.get(sfd_idx).map(|sfd| sfd.mask).ok_or(Errno::EBADF) + })?; // Find a pending signal matching the mask - let pending = proc.signals.pending_mask(); - let matching = pending & mask; - if matching == 0 { - return Err(Errno::EAGAIN); - } - // Re-read mask and find signal - let mask = proc - .signalfds - .get(sfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)? - .mask; - let pending = proc.signals.pending_mask(); + let tid = crate::process_table::current_tid(); + let pending = proc.pending_for(tid); let matching = pending & mask; if matching == 0 { return Err(Errno::EAGAIN); } // Find lowest matching signal (bit N = signal N+1, musl 0-based convention) let signo = matching.trailing_zeros() + 1; - // Consume the signal from pending - proc.signals.clear_pending(signo); - // Write signalfd_siginfo (128 bytes): only ssi_signo at offset 0 + let info = proc + .consume_signal_for(tid, signo) + .ok_or(Errno::EAGAIN)?; + let (sender_pid, sender_uid, timer_id, overrun) = match info.timer_id { + Some(timer_id) => ( + 0, + 0, + timer_id, + proc.accept_posix_timer_notification(timer_id).unwrap_or(0), + ), + None => (proc.pid, proc.uid, 0, 0), + }; + // Write the signal-specific fields in Linux signalfd_siginfo. for b in buf[..128].iter_mut() { *b = 0; } buf[..4].copy_from_slice(&signo.to_le_bytes()); + buf[8..12].copy_from_slice(&info.si_code.to_le_bytes()); + buf[12..16].copy_from_slice(&sender_pid.to_le_bytes()); + buf[16..20].copy_from_slice(&sender_uid.to_le_bytes()); + buf[24..28].copy_from_slice(&timer_id.to_le_bytes()); + buf[32..36].copy_from_slice(&overrun.to_le_bytes()); + buf[44..48].copy_from_slice(&info.si_value.to_le_bytes()); + buf[48..56].copy_from_slice(&(info.si_value as u32 as u64).to_le_bytes()); Ok(128) } FileType::EventFd => { @@ -2383,27 +2893,20 @@ pub fn sys_read( return Err(Errno::EINVAL); } let efd_idx = (-(host_handle + 1)) as usize; - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - if efd.counter == 0 { - return Err(Errno::EAGAIN); - } - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let value = if efd.semaphore { - efd.counter -= 1; - 1u64 - } else { - let v = efd.counter; - efd.counter = 0; - v - }; + let value = crate::descriptor_backing::with_eventfds(|table| { + let efd = table.get_mut(efd_idx).ok_or(Errno::EBADF)?; + if efd.counter == 0 { + return Err(Errno::EAGAIN); + } + if efd.semaphore { + efd.counter -= 1; + Ok(1u64) + } else { + let value = efd.counter; + efd.counter = 0; + Ok(value) + } + })?; buf[..8].copy_from_slice(&value.to_le_bytes()); Ok(8) } @@ -2526,45 +3029,35 @@ pub fn sys_read( } } // procfs: read from snapshot buffer - if crate::procfs::is_procfs_buf_handle(host_handle) { + if file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle) { let buf_idx = crate::procfs::procfs_buf_idx(host_handle); - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let offset = ofd.offset as usize; - let data = proc - .procfs_bufs - .get(buf_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)?; - if offset >= data.len() { - return Ok(0); // EOF - } - let remaining = &data[offset..]; - let n = buf.len().min(remaining.len()); - buf[..n].copy_from_slice(&remaining[..n]); - let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; - ofd.offset += n as i64; - return Ok(n); + return crate::descriptor_backing::with_procfs_bufs(|table| { + let backing = table.get_mut(buf_idx).ok_or(Errno::EBADF)?; + let offset = usize::try_from(backing.offset).map_err(|_| Errno::EOVERFLOW)?; + if offset >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - offset); + buf[..n].copy_from_slice(&backing.data[offset..offset + n]); + backing.offset += n as i64; + Ok(n) + }); } // memfd: read from in-memory buffer if file_type == FileType::MemFd { let memfd_idx = (-(host_handle + 1)) as usize; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let offset = ofd.offset as usize; - let data = proc - .memfds - .get(memfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)?; - if offset >= data.len() { - return Ok(0); // EOF - } - let remaining = &data[offset..]; - let n = buf.len().min(remaining.len()); - buf[..n].copy_from_slice(&remaining[..n]); - let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; - ofd.offset += n as i64; - return Ok(n); + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + let offset = usize::try_from(backing.offset).map_err(|_| Errno::EOVERFLOW)?; + if offset >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - offset); + buf[..n].copy_from_slice(&backing.data[offset..offset + n]); + backing.offset += n as i64; + Ok(n) + }); } if host_handle == SYNTHETIC_FILE_HANDLE { @@ -2680,16 +3173,21 @@ pub fn sys_write( return Err(Errno::EAGAIN); } } - // External path: delegate to host + // External path: delegate to host. A stream write that + // fails with EPIPE must also generate SIGPIPE; the host + // only reports the errno, so mirror the POSIX side effect + // in the kernel just like the in-kernel pipe-backed TCP + // path above. let net_handle = sock.host_net_handle.ok_or(Errno::ENOTCONN)?; - host.host_net_send(net_handle, buf, 0) + match host.host_net_send(net_handle, buf, 0) { + Err(Errno::EPIPE) => { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + Err(Errno::EPIPE) + } + other => other, + } } SocketDomain::Unix => { - if sock.send_buf_idx.is_none() - && sock.sock_type == crate::socket::SocketType::Dgram - { - return Ok(buf.len()); // bit-bucket for SOCK_DGRAM (syslog pattern) - } let send_buf_idx = sock.send_buf_idx.ok_or(Errno::ENOTCONN)?; loop { let pipe = @@ -2718,21 +3216,15 @@ pub fn sys_write( return Err(Errno::EINVAL); } let efd_idx = (-(host_handle + 1)) as usize; - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let max_val = u64::MAX - 1; - if efd.counter > max_val - value { - return Err(Errno::EAGAIN); - } - let efd = proc - .eventfds - .get_mut(efd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - efd.counter += value; + crate::descriptor_backing::with_eventfds(|table| { + let efd = table.get_mut(efd_idx).ok_or(Errno::EBADF)?; + let max_val = u64::MAX - 1; + if efd.counter > max_val - value { + return Err(Errno::EAGAIN); + } + efd.counter += value; + Ok(()) + })?; Ok(8) } FileType::PtyMaster => { @@ -2760,26 +3252,6 @@ pub fn sys_write( Ok(n) } _ => { - // memfd: write to in-memory buffer - if file_type == FileType::MemFd { - let memfd_idx = (-(host_handle + 1)) as usize; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - let offset = ofd.offset as usize; - let data = proc - .memfds - .get_mut(memfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - let end = offset + buf.len(); - if end > data.len() { - data.resize(end, 0); - } - data[offset..end].copy_from_slice(buf); - let ofd = proc.ofd_table.get_mut(ofd_idx).ok_or(Errno::EBADF)?; - ofd.offset += buf.len() as i64; - return Ok(buf.len()); - } - // Virtual character devices — handle in-kernel if file_type == FileType::CharDevice { if let Some(dev) = VirtualDevice::from_host_handle(host_handle) { @@ -2840,24 +3312,45 @@ pub fn sys_write( }; } } - // O_APPEND: seek to end before writing (POSIX atomicity guaranteed by serialized kernel syscalls) - if status_flags & O_APPEND != 0 { + + // Compute RLIMIT_FSIZE once for this logical write. For regular + // files and memfds this resolves the authoritative append or + // open-file-description offset without changing either cursor. + let writable_len = write_operation_budget(proc, host, fd, None, buf.len())?; + + // memfd: write to the shared in-memory backing. Apply O_APPEND + // only at the actual non-empty mutation boundary. + if file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + if writable_len > 0 && status_flags & O_APPEND != 0 { + backing.offset = + i64::try_from(backing.data.len()).map_err(|_| Errno::EOVERFLOW)?; + } + let offset = usize::try_from(backing.offset).map_err(|_| Errno::EOVERFLOW)?; + let end = offset.checked_add(writable_len).ok_or(Errno::EFBIG)?; + if end > backing.data.len() { + backing.data.resize(end, 0); + } + backing.data[offset..end].copy_from_slice(&buf[..writable_len]); + backing.offset = backing + .offset + .checked_add(i64::try_from(writable_len).map_err(|_| Errno::EOVERFLOW)?) + .ok_or(Errno::EOVERFLOW)?; + Ok(writable_len) + }); + } + + // O_APPEND positioning belongs to the actual non-empty write, not + // the side-effect-free operation-budget query. + if writable_len > 0 && status_flags & O_APPEND != 0 { let end = host.host_seek(host_handle, 0, 2)?; // SEEK_END if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.offset = end; } } - // RLIMIT_FSIZE: check if write would exceed file size limit - let fsize_limit = proc.rlimits[1][0]; // RLIMIT_FSIZE soft limit - if fsize_limit != u64::MAX { - let current_offset = proc.ofd_table.get(ofd_idx).map_or(0, |o| o.offset); - let end_pos = current_offset as u64 + buf.len() as u64; - if end_pos > fsize_limit { - proc.signals.raise(wasm_posix_shared::signal::SIGXFSZ); - return Err(Errno::EFBIG); - } - } - let n = host.host_write(host_handle, buf)?; + let n = host.host_write(host_handle, &buf[..writable_len])?; if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { ofd.offset += n as i64; } @@ -2899,6 +3392,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); @@ -2953,8 +3449,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 { @@ -2972,6 +3470,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; @@ -2981,23 +3482,26 @@ pub fn sys_lseek( } // Procfs file buffers: compute offset against snapshot length - if crate::procfs::is_procfs_buf_handle(ofd.host_handle) { + if ofd.file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(ofd.host_handle) { let buf_idx = crate::procfs::procfs_buf_idx(ofd.host_handle); - let size = proc - .procfs_bufs - .get(buf_idx) - .and_then(|s| s.as_ref()) - .map_or(0, |d| d.len() as i64); + let size = crate::descriptor_backing::with_procfs_bufs(|table| { + table + .get(buf_idx) + .map(|backing| backing.data.len() as i64) + .ok_or(Errno::EBADF) + })?; + let current = + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; let new_pos = match whence { SEEK_SET => offset, - SEEK_CUR => ofd.offset + offset, - SEEK_END => size + offset, + SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, + SEEK_END => size.checked_add(offset).ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; if new_pos < 0 { return Err(Errno::EINVAL); } - ofd.offset = new_pos; + crate::descriptor_backing::set_current_offset(ofd.file_type, ofd.host_handle, new_pos)?; return Ok(new_pos); } @@ -3005,8 +3509,8 @@ 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_END => size + 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), }; if new_pos < 0 { @@ -3019,31 +3523,40 @@ pub fn sys_lseek( // MemFd: compute offset against in-memory buffer if ofd.file_type == FileType::MemFd { let memfd_idx = (-(ofd.host_handle + 1)) as usize; - let size = proc - .memfds - .get(memfd_idx) - .and_then(|s| s.as_ref()) - .map_or(0, |d| d.len() as i64); + let size = crate::descriptor_backing::with_memfds(|table| { + table + .get(memfd_idx) + .map(|backing| backing.data.len() as i64) + .ok_or(Errno::EBADF) + })?; + let current = + crate::descriptor_backing::current_offset(ofd.file_type, ofd.host_handle, ofd.offset)?; let new_pos = match whence { SEEK_SET => offset, - SEEK_CUR => ofd.offset + offset, - SEEK_END => size + offset, + SEEK_CUR => current.checked_add(offset).ok_or(Errno::EOVERFLOW)?, + SEEK_END => size.checked_add(offset).ok_or(Errno::EOVERFLOW)?, _ => return Err(Errno::EINVAL), }; if new_pos < 0 { return Err(Errno::EINVAL); } - ofd.offset = new_pos; + crate::descriptor_backing::set_current_offset(ofd.file_type, ofd.host_handle, new_pos)?; return Ok(new_pos); } 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 } @@ -3099,7 +3612,7 @@ pub fn sys_pread( if host_handle == SYNTHETIC_FILE_HANDLE { let data = synthetic_file_content(&ofd.path).ok_or(Errno::EBADF)?; - let start = offset as usize; + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; if start >= data.len() { return Ok(0); } @@ -3108,6 +3621,35 @@ pub fn sys_pread( return Ok(n); } + if ofd.file_type == FileType::Regular && crate::procfs::is_procfs_buf_handle(host_handle) { + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + return crate::descriptor_backing::with_procfs_bufs(|table| { + let backing = table + .get(crate::procfs::procfs_buf_idx(host_handle)) + .ok_or(Errno::EBADF)?; + if start >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - start); + buf[..n].copy_from_slice(&backing.data[start..start + n]); + Ok(n) + }); + } + + if ofd.file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get(memfd_idx).ok_or(Errno::EBADF)?; + if start >= backing.data.len() { + return Ok(0); + } + let n = buf.len().min(backing.data.len() - start); + buf[..n].copy_from_slice(&backing.data[start..start + n]); + Ok(n) + }); + } + // Seek to the requested offset, read, then restore. // Single-threaded, so save/seek/read/restore is safe. host.host_seek(host_handle, offset, SEEK_SET)?; @@ -3117,6 +3659,141 @@ pub fn sys_pread( Ok(n) } +/// Queue a synchronous file-size-limit signal for the thread that issued the +/// write. A worker thread must not redirect SIGXFSZ through the process-shared +/// pending set to a different thread that happens to have it unblocked. +fn raise_fsize_signal_for_caller(proc: &mut Process) { + let tid = crate::process_table::current_tid(); + if !proc.raise_for_thread(tid, SIGXFSZ) { + // A stale host TID must not lose the required signal. The shared + // queue is the conservative fallback used by the existing signal + // entry points for unknown thread identities. + proc.signals.raise(SIGXFSZ); + } +} + +/// Apply POSIX RLIMIT_FSIZE semantics to one regular-file write operation. +/// +/// A write that starts before the soft file-size limit may complete partially +/// up to the limit. Only a non-empty operation with no byte available at its +/// starting offset fails with EFBIG and generates SIGXFSZ. +fn fsize_limited_write_len( + proc: &mut Process, + offset: u64, + requested_len: usize, +) -> Result { + if requested_len == 0 { + return Ok(0); + } + let fsize_limit = proc.rlimits[RLIMIT_FSIZE as usize][0]; + if fsize_limit == RLIM_INFINITY { + return Ok(requested_len); + } + if offset >= fsize_limit { + raise_fsize_signal_for_caller(proc); + return Err(Errno::EFBIG); + } + // Convert only after comparing in u64. The kernel itself is wasm32 even + // for wasm64 guests, so a remaining budget above 4 GiB must not wrap when + // represented as usize. + let available = fsize_limit - offset; + match usize::try_from(available) { + Ok(available) => Ok(requested_len.min(available)), + Err(_) => Ok(requested_len), + } +} + +/// Resolve the writable prefix for a single top-level write operation. +/// +/// `offset` is `Some` for positioned operations and `None` for operations +/// using the open-file-description cursor. O_APPEND observes the current file +/// size without moving either the host or kernel cursor; the actual write owns +/// append positioning. Non-regular objects are validated for write access but +/// are not constrained by RLIMIT_FSIZE. +pub(crate) fn write_operation_budget( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, + offset: Option, + requested_len: usize, +) -> Result { + let entry = proc.fd_table.get(fd)?; + let ofd_idx = entry.ofd_ref.0; + let (file_type, status_flags, host_handle, current_offset) = { + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + ( + ofd.file_type, + ofd.status_flags, + ofd.host_handle, + ofd.offset, + ) + }; + + if status_flags & O_ACCMODE == O_RDONLY { + return Err(Errno::EBADF); + } + if requested_len == 0 { + return Ok(0); + } + if !matches!(file_type, FileType::Regular | FileType::MemFd) { + return Ok(requested_len); + } + + let start = if let Some(offset) = offset { + u64::try_from(offset).map_err(|_| Errno::EINVAL)? + } else if file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + crate::descriptor_backing::with_memfds(|table| { + let backing = table.get(memfd_idx).ok_or(Errno::EBADF)?; + if status_flags & O_APPEND != 0 { + u64::try_from(backing.data.len()).map_err(|_| Errno::EOVERFLOW) + } else { + u64::try_from(backing.offset).map_err(|_| Errno::EINVAL) + } + })? + } else { + if status_flags & O_APPEND != 0 { + host.host_fstat(host_handle)?.st_size + } else { + u64::try_from(current_offset).map_err(|_| Errno::EINVAL)? + } + }; + + fsize_limited_write_len(proc, start, requested_len) +} + +/// Validate a transfer source without consuming data or changing its cursor. +/// Output RLIMIT checks must not hide an invalid input descriptor. +fn validate_transfer_input(proc: &Process, fd: i32, offset: Option) -> Result<(), Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc + .ofd_table + .get(entry.ofd_ref.0) + .ok_or(Errno::EBADF)?; + if ofd.status_flags & O_ACCMODE == O_WRONLY { + return Err(Errno::EBADF); + } + if matches!(offset, Some(value) if value < 0) { + return Err(Errno::EINVAL); + } + if offset.is_some() + && matches!( + ofd.file_type, + FileType::Pipe + | FileType::Socket + | FileType::EventFd + | FileType::Epoll + | FileType::TimerFd + | FileType::SignalFd + | FileType::PtyMaster + | FileType::PtySlave + ) + { + return Err(Errno::ESPIPE); + } + Ok(()) +} + /// Write to a file descriptor at a given offset without modifying the file position. pub fn sys_pwrite( proc: &mut Process, @@ -3152,10 +3829,26 @@ pub fn sys_pwrite( } let host_handle = ofd.host_handle; + let file_type = ofd.file_type; let saved_offset = ofd.offset; + let writable_len = write_operation_budget(proc, host, fd, Some(offset), buf.len())?; + + if file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + let start = usize::try_from(offset).map_err(|_| Errno::EOVERFLOW)?; + let end = start.checked_add(writable_len).ok_or(Errno::EFBIG)?; + return crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + if end > backing.data.len() { + backing.data.resize(end, 0); + } + backing.data[start..end].copy_from_slice(&buf[..writable_len]); + Ok(writable_len) + }); + } host.host_seek(host_handle, offset, SEEK_SET)?; - let n = host.host_write(host_handle, buf)?; + let n = host.host_write(host_handle, &buf[..writable_len])?; host.host_seek(host_handle, saved_offset, SEEK_SET)?; Ok(n) @@ -3187,6 +3880,19 @@ pub fn sys_preadv( Ok(total) } +/// Validate the total byte count represented by one wasm32 scatter/gather +/// operation. Syscall return values are signed 32-bit even when the guest uses +/// memory64, so a larger aggregate cannot be reported faithfully. +fn checked_iovec_len(iovecs: &[&[u8]]) -> Result { + let total = iovecs.iter().try_fold(0usize, |total, buf| { + total.checked_add(buf.len()).ok_or(Errno::EINVAL) + })?; + if total > i32::MAX as usize { + return Err(Errno::EINVAL); + } + Ok(total) +} + /// pwritev -- scatter-gather write at offset. /// Writes from multiple buffers to a file descriptor at the given offset /// without modifying the file position. @@ -3197,16 +3903,30 @@ pub fn sys_pwritev( iovecs: &[&[u8]], offset: i64, ) -> Result { + let requested_len = checked_iovec_len(iovecs)?; + let writable_len = + write_operation_budget(proc, host, fd, Some(offset), requested_len)?; let mut total = 0usize; let mut cur_offset = offset; for buf in iovecs { + if total == writable_len { + break; + } if buf.is_empty() { continue; } - let n = sys_pwrite(proc, host, fd, buf, cur_offset)?; + let operation_remaining = writable_len - total; + let attempted = buf.len().min(operation_remaining); + let n = match sys_pwrite(proc, host, fd, &buf[..attempted], cur_offset) { + Ok(n) => n, + Err(_) if total > 0 => return Ok(total), + Err(e) => return Err(e), + }; total += n; - cur_offset += n as i64; - if n < buf.len() { + cur_offset = cur_offset + .checked_add(i64::try_from(n).map_err(|_| Errno::EOVERFLOW)?) + .ok_or(Errno::EOVERFLOW)?; + if n < attempted || total == writable_len { break; // Short write } } @@ -3225,12 +3945,17 @@ pub fn sys_sendfile( offset: i64, count: usize, ) -> Result { + if count == 0 { + return Ok(0); + } + validate_transfer_input(proc, in_fd, (offset >= 0).then_some(offset))?; + let writable_len = write_operation_budget(proc, host, out_fd, None, count)?; let mut total = 0usize; let mut buf = [0u8; 4096]; let mut cur_offset = offset; - while total < count { - let to_read = (count - total).min(buf.len()); + while total < writable_len { + let to_read = (writable_len - total).min(buf.len()); let n = if offset >= 0 { match sys_pread(proc, host, in_fd, &mut buf[..to_read], cur_offset) { Ok(n) => { @@ -3291,13 +4016,18 @@ pub fn sys_copy_file_range( off_out: Option, len: usize, ) -> Result { + if len == 0 { + return Ok(0); + } + validate_transfer_input(proc, fd_in, off_in)?; + let writable_len = write_operation_budget(proc, host, fd_out, off_out, len)?; let mut total = 0usize; let mut buf = [0u8; 4096]; let mut cur_off_in = off_in.unwrap_or(-1); let mut cur_off_out = off_out.unwrap_or(-1); - while total < len { - let to_read = (len - total).min(buf.len()); + while total < writable_len { + let to_read = (writable_len - total).min(buf.len()); let n = if off_in.is_some() { match sys_pread(proc, host, fd_in, &mut buf[..to_read], cur_off_in) { Ok(n) => { @@ -3628,11 +4358,12 @@ pub fn sys_fstat(proc: &mut Process, host: &mut dyn HostIO, fd: i32) -> Result Result flock.l_start, // SEEK_SET - 1 => offset + flock.l_start, // SEEK_CUR + 0 => flock.l_start, // SEEK_SET + 1 => offset.checked_add(flock.l_start).ok_or(Errno::EOVERFLOW)?, // SEEK_CUR 2 => { // SEEK_END: resolve relative to file size if host_handle >= 0 { @@ -3981,7 +4717,6 @@ pub fn sys_flock( sys_fcntl_lock(proc, fd, cmd, &mut flock, host) } -use crate::path::resolve_path; use crate::process::{DirStream, ProcessState}; use wasm_posix_shared::WasmDirent; @@ -4010,8 +4745,33 @@ fn match_pty_stat(resolved: &[u8], uid: u32, gid: u32) -> Option { } } +fn unix_socket_path_stat( + proc: &Process, + host: &mut dyn HostIO, + resolved: &[u8], + follow: bool, +) -> Result, Errno> { + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + if !registry.contains(resolved) { + return Ok(None); + } + + // Filesystem-backed AF_UNIX sockets have ordinary path metadata. The + // socket registry tells Kandelo that the path should report S_IFSOCK, but + // uid/gid/mode/timestamps still come from the underlying VFS inode so + // chown(2), chmod(2), and stat(2) round-trip like a POSIX socket node. + check_search_path(proc, host, resolved)?; + let mut st = if follow { + host.host_stat(resolved)? + } else { + host.host_lstat(resolved)? + }; + st.st_mode = wasm_posix_shared::mode::S_IFSOCK | (st.st_mode & 0o7777); + Ok(Some(st)) +} + pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result { - let resolved = resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; if let Some(dev) = match_virtual_device(&resolved) { return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); } @@ -4038,35 +4798,19 @@ pub fn sys_stat(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Resul }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; return Ok(crate::procfs::procfs_stat(&entry, 0, true)); } - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { - return Ok(st); + if !is_host_backed_devfs_path(&resolved) { + if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { + return Ok(st); + } } if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - // Check Unix socket registry - { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - if registry.contains(&resolved) { - return Ok(WasmStat { - st_dev: 0, - st_ino: 0x554E5800, // "UNX\0" - st_mode: wasm_posix_shared::mode::S_IFSOCK | 0o755, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); - } + if let Some(st) = unix_socket_path_stat(proc, host, &resolved, true)? { + return Ok(st); } // VFS is the source of truth for ownership: host_stat already returns the // file's real uid/gid, so just propagate. @@ -4079,7 +4823,7 @@ pub fn sys_lstat( host: &mut dyn HostIO, path: &[u8], ) -> Result { - let resolved = resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?.path; if let Some(dev) = match_virtual_device(&resolved) { return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); } @@ -4106,35 +4850,19 @@ pub fn sys_lstat( }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; return Ok(crate::procfs::procfs_stat(&entry, 0, false)); } - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { - return Ok(st); + if !is_host_backed_devfs_path(&resolved) { + if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { + return Ok(st); + } } if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - // Check Unix socket registry - { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - if registry.contains(&resolved) { - return Ok(WasmStat { - st_dev: 0, - st_ino: 0x554E5800, // "UNX\0" - st_mode: wasm_posix_shared::mode::S_IFSOCK | 0o755, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); - } + if let Some(st) = unix_socket_path_stat(proc, host, &resolved, false)? { + return Ok(st); } // VFS is the source of truth for ownership: host_lstat already returns the // link's real uid/gid, so just propagate. @@ -4148,7 +4876,9 @@ pub fn sys_mkdir( path: &[u8], mode: u32, ) -> Result<(), Errno> { - let resolved = resolve_path(path, &proc.cwd); + let resolved = + resolve_namespace_path(proc, host, path, PathResolveOptions::CREATE_DIRECTORY)?.path; + ensure_host_mutable_namespace_path(&resolved)?; let effective_mode = mode & !proc.umask; check_parent_writable(proc, host, &resolved)?; host.host_mkdir(&resolved, effective_mode)?; @@ -4156,14 +4886,16 @@ pub fn sys_mkdir( } pub fn sys_rmdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { - let resolved = resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?.path; + ensure_host_mutable_namespace_path(&resolved)?; check_parent_writable(proc, host, &resolved)?; check_sticky_child(proc, host, &resolved)?; host.host_rmdir(&resolved) } pub fn sys_unlink(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { - let resolved = resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?.path; + ensure_host_mutable_namespace_path(&resolved)?; check_parent_writable(proc, host, &resolved)?; check_sticky_child(proc, host, &resolved)?; // AF_UNIX bind() creates a real host inode, so unlink must remove both the @@ -4172,6 +4904,10 @@ pub fn sys_unlink(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Res { let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; if registry.unregister(&resolved) { + // A sendto(path) may be parked on a full AF_UNIX datagram queue. + // Once the name is removed, retry it so it observes the now-stale + // destination instead of sleeping until an unrelated timeout. + crate::wakeup::push_datagram_writable(); match host.host_unlink(&resolved) { Ok(()) | Err(Errno::ENOENT) => return Ok(()), Err(e) => return Err(e), @@ -4200,15 +4936,31 @@ pub fn sys_rename( oldpath: &[u8], newpath: &[u8], ) -> Result<(), Errno> { - let old = resolve_path(oldpath, &proc.cwd); - let new = resolve_path(newpath, &proc.cwd); + let old_entry = resolve_namespace_path(proc, host, oldpath, PathResolveOptions::NOFOLLOW)?; + let new_options = if old_entry + .stat + .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFDIR) + { + PathResolveOptions::CREATE_DIRECTORY + } else { + PathResolveOptions::CREATE_ENTRY + }; + let new = resolve_namespace_path(proc, host, newpath, new_options)?.path; + let old = old_entry.path; + ensure_host_mutable_namespace_path(&old)?; + ensure_host_mutable_namespace_path(&new)?; check_parent_writable(proc, host, &old)?; check_parent_writable(proc, host, &new)?; check_sticky_child(proc, host, &old)?; if host.host_lstat(&new).is_ok() { check_sticky_child(proc, host, &new)?; } - host.host_rename(&old, &new) + host.host_rename(&old, &new)?; + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + if registry.rename_path(&old, &new) { + crate::wakeup::push_datagram_writable(); + } + Ok(()) } pub fn sys_link( @@ -4217,8 +4969,10 @@ pub fn sys_link( oldpath: &[u8], newpath: &[u8], ) -> Result<(), Errno> { - let old = resolve_path(oldpath, &proc.cwd); - let new = resolve_path(newpath, &proc.cwd); + let old = resolve_namespace_path(proc, host, oldpath, PathResolveOptions::NOFOLLOW)?.path; + let new = resolve_namespace_path(proc, host, newpath, PathResolveOptions::CREATE_ENTRY)?.path; + ensure_host_mutable_namespace_path(&old)?; + ensure_host_mutable_namespace_path(&new)?; check_search_path(proc, host, &old)?; check_parent_writable(proc, host, &new)?; host.host_link(&old, &new) @@ -4231,7 +4985,9 @@ pub fn sys_symlink( linkpath: &[u8], ) -> Result<(), Errno> { // Note: symlink target is stored as-is (not resolved), but linkpath is resolved - let link = resolve_path(linkpath, &proc.cwd); + let link = + resolve_namespace_path(proc, host, linkpath, PathResolveOptions::CREATE_ENTRY)?.path; + ensure_host_mutable_namespace_path(&link)?; check_parent_writable(proc, host, &link)?; host.host_symlink(target, &link) } @@ -4242,10 +4998,11 @@ pub fn sys_readlink( path: &[u8], buf: &mut [u8], ) -> Result { - let resolved = resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?.path; // Procfs symlinks — /proc/self, /proc/self/fd/N, /proc/self/cwd, /proc/self/exe, etc. if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; if entry.is_symlink() { return crate::procfs::procfs_readlink(proc, &entry, buf); } @@ -4263,86 +5020,110 @@ pub fn sys_chmod( path: &[u8], mode: u32, ) -> Result<(), Errno> { - let resolved = resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; + ensure_host_mutable_namespace_path(&resolved)?; check_search_path(proc, host, &resolved)?; let st = host.host_stat(&resolved)?; check_owner_or_root(proc, &st)?; host.host_chmod(&resolved, mode) } -pub fn sys_chown( - proc: &mut Process, - host: &mut dyn HostIO, - path: &[u8], +const CHOWN_ID_UNCHANGED: u32 = u32::MAX; + +fn prepare_chown_ids( + proc: &Process, + st: &WasmStat, uid: u32, gid: u32, -) -> Result<(), Errno> { - let resolved = resolve_path(path, &proc.cwd); - if proc.euid != 0 { +) -> Result<(u32, u32), Errno> { + let both_unchanged = uid == CHOWN_ID_UNCHANGED && gid == CHOWN_ID_UNCHANGED; + if proc.euid != 0 && !(both_unchanged && proc.euid == st.st_uid) { return Err(Errno::EPERM); } - check_search_path(proc, host, &resolved)?; + Ok(( + if uid == CHOWN_ID_UNCHANGED { + st.st_uid + } else { + uid + }, + if gid == CHOWN_ID_UNCHANGED { + st.st_gid + } else { + gid + }, + )) +} + +pub fn sys_chown( + proc: &mut Process, + host: &mut dyn HostIO, + path: &[u8], + uid: u32, + gid: u32, +) -> Result<(), Errno> { + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; + ensure_host_mutable_namespace_path(&resolved)?; + check_search_path(proc, host, &resolved)?; + let st = host.host_stat(&resolved)?; + let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; host.host_chown(&resolved, uid, gid) } +pub fn sys_lchown( + proc: &mut Process, + host: &mut dyn HostIO, + path: &[u8], + uid: u32, + gid: u32, +) -> Result<(), Errno> { + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::NOFOLLOW)?; + ensure_host_mutable_namespace_path(&resolved.path)?; + check_search_path(proc, host, &resolved.path)?; + let st = resolved.stat.ok_or(Errno::ENOENT)?; + let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; + host.host_lchown(&resolved.path, uid, gid) +} + pub fn sys_access( proc: &mut Process, host: &mut dyn HostIO, path: &[u8], amode: u32, ) -> Result<(), Errno> { - let resolved = resolve_path(path, &proc.cwd); if amode & !(R_OK | W_OK | X_OK) != 0 { return Err(Errno::EINVAL); } - if match_virtual_device(&resolved).is_some() - || match_dev_fd(&resolved).is_some() - || match_pty_stat(&resolved, 0, 0).is_some() - || crate::devfs::match_devfs_dir(&resolved).is_some() + let resolved = resolve_namespace_path( + proc, + host, + path, + PathResolveOptions { + use_real_ids: true, + ..PathResolveOptions::FOLLOW + }, + )?; + if amode & W_OK != 0 + && (is_procfs_namespace_path(&resolved.path) + || synthetic_file_content(&resolved.path).is_some()) { - return Ok(()); - } - if crate::procfs::match_procfs(&resolved, proc.pid).is_some() { - // Procfs entries are read-only: allow R_OK/F_OK/X_OK(dirs), deny W_OK - if amode & 0o2 != 0 { - return Err(Errno::EACCES); - } - return Ok(()); + return Err(Errno::EACCES); } - check_search_path(proc, host, &resolved)?; - let st = host.host_stat(&resolved)?; + let st = resolved.stat.ok_or(Errno::ENOENT)?; check_access_for_ids(proc.uid, proc.gid, &st, amode) } /// Change the current working directory. -/// Validates that the path exists and is a directory via host_stat. +/// Resolves through the global namespace, then validates the directory and +/// search permission before storing its canonical pathname. pub fn sys_chdir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result<(), Errno> { - let resolved = crate::path::resolve_path(path, &proc.cwd); - // Check virtual filesystems first (procfs, devfs), then fall through to host - if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { - let st = crate::procfs::procfs_stat(&entry, 0, true); - if st.st_mode & wasm_posix_shared::mode::S_IFMT != wasm_posix_shared::mode::S_IFDIR { - return Err(Errno::ENOTDIR); - } - proc.cwd = resolved; - return Ok(()); - } - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { - if st.st_mode & wasm_posix_shared::mode::S_IFMT != wasm_posix_shared::mode::S_IFDIR { - return Err(Errno::ENOTDIR); - } - proc.cwd = resolved; - return Ok(()); - } - // Validate the path exists and is a directory - check_search_path(proc, host, &resolved)?; - let stat = host.host_stat(&resolved)?; + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?; + let stat = resolved.stat.ok_or(Errno::ENOENT)?; let file_type = stat.st_mode & wasm_posix_shared::mode::S_IFMT; if file_type != wasm_posix_shared::mode::S_IFDIR { return Err(Errno::ENOTDIR); } check_access(proc, &stat, X_OK)?; - proc.cwd = resolved; + proc.cwd = resolved.path; Ok(()) } @@ -4360,7 +5141,15 @@ pub fn sys_fchdir(proc: &mut Process, fd: i32) -> Result<(), Errno> { /// Get the current working directory. /// Writes the cwd path to `buf` and returns the number of bytes written. /// Returns ERANGE if the buffer is too small. -pub fn sys_getcwd(proc: &Process, buf: &mut [u8]) -> Result { +pub fn sys_getcwd( + proc: &Process, + host: &mut dyn HostIO, + buf: &mut [u8], +) -> Result { + let stat = namespace_lstat_raw(proc, host, &proc.cwd)?; + if stat.st_mode & S_IFMT != S_IFDIR { + return Err(Errno::ENOENT); + } // Linux getcwd returns the path WITH a null terminator and the length // includes the null byte. musl expects this convention. let needed = proc.cwd.len() + 1; // +1 for NUL @@ -4374,17 +5163,26 @@ pub fn sys_getcwd(proc: &Process, buf: &mut [u8]) -> Result { /// Open a directory for reading. Returns a directory stream handle. pub fn sys_opendir(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Result { - let resolved = crate::path::resolve_path(path, &proc.cwd); - check_search_path(proc, host, &resolved)?; - let st = host.host_stat(&resolved)?; + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?; + let st = resolved.stat.ok_or(Errno::ENOENT)?; if st.st_mode & S_IFMT != S_IFDIR { return Err(Errno::ENOTDIR); } check_access(proc, &st, R_OK | X_OK)?; - let host_handle = host.host_opendir(&resolved)?; + if is_procfs_namespace_path(&resolved.path) + || (is_devfs_namespace_path(&resolved.path) + && !is_host_backed_devfs_path(&resolved.path)) + { + // Kernel-owned procfs/devfs directory iteration is implemented by + // open(O_DIRECTORY)+getdents64. This legacy directory-stream API has + // no synthetic-stream sentinel, so fail truthfully instead of routing + // the name into a hidden host directory. + return Err(Errno::EOPNOTSUPP); + } + let host_handle = host.host_opendir(&resolved.path)?; let stream = DirStream { host_handle, - path: resolved, + path: resolved.path, position: 0, synth_dot_state: 0, }; @@ -4661,20 +5459,26 @@ pub fn sys_getdents64( // Check if this is a cross-process directory (e.g. /proc//fd) #[cfg(any(target_arch = "wasm32", target_arch = "wasm64"))] if let Some(entry) = crate::procfs::match_procfs(&path, proc.pid) { - let target_pid = crate::procfs::entry_pid(&entry); - if target_pid != 0 && target_pid != proc.pid { - if let Some((bytes, new_offset, exhausted)) = - crate::wasm_api::procfs_getdents64_for_pid(target_pid, &path, buf, entry_offset) - { - if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { - ofd.dir_entry_offset = new_offset; - if exhausted { - ofd.dir_host_handle = -2; + if let Some(target_pid) = crate::procfs::entry_pid(&entry) { + if target_pid != proc.pid { + if let Some((bytes, new_offset, exhausted)) = + crate::wasm_api::procfs_getdents64_for_pid( + target_pid, + &path, + buf, + entry_offset, + ) + { + if let Some(ofd) = proc.ofd_table.get_mut(ofd_idx) { + ofd.dir_entry_offset = new_offset; + if exhausted { + ofd.dir_host_handle = -2; + } } + return Ok(bytes); } - return Ok(bytes); + return Err(Errno::ENOENT); } - return Err(Errno::ENOENT); } } @@ -5024,7 +5828,7 @@ pub fn sys_kill( } } if is_local { - proc.signals.raise(sig); + proc.raise_signal(sig); Ok(()) } else { host.host_kill(pid, sig) @@ -5059,7 +5863,7 @@ pub fn sys_execve(proc: &mut Process, host: &mut dyn HostIO, path: &[u8]) -> Res } // Resolve and validate before tearing down mappings. POSIX exec // failure must leave the current image intact. - let resolved = crate::path::resolve_path(path, &proc.cwd); + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; check_exec_path(proc, host, &resolved)?; release_exec_image_state(proc, host); host.host_exec(&resolved) @@ -5091,23 +5895,8 @@ pub fn sys_execveat( host.host_exec(&exec_path) } else if path.is_empty() { Err(Errno::ENOENT) - } else if path[0] == b'/' { - // Absolute path — ignore dirfd - check_exec_path(proc, host, path)?; - release_exec_image_state(proc, host); - host.host_exec(path) } else { - // Relative path — resolve against dirfd or CWD - let base = if dirfd == -100 { - // AT_FDCWD - proc.cwd.clone() - } else { - let entry = proc.fd_table.get(dirfd)?; - let ofd_idx = entry.ofd_ref.0; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; - ofd.path.clone() - }; - let resolved = crate::path::resolve_path(path, &base); + let resolved = resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path; check_exec_path(proc, host, &resolved)?; release_exec_image_state(proc, host); host.host_exec(&resolved) @@ -5247,13 +6036,15 @@ pub fn sys_getitimer( /// Checks if any signal in `mask` is already pending and dequeues it. /// Blocking waits are retried by the host; this returns EAGAIN when no /// matching signal is immediately pending. -/// Returns (signum, si_value, si_code) on success. +/// Returns `(signum, si_value, si_code, siginfo_word_1, siginfo_word_2)` on +/// success. The final words are `si_pid`/`si_uid` for ordinary signals and +/// `si_timerid`/`si_overrun` for `SI_TIMER`. pub fn sys_sigtimedwait( proc: &mut Process, _host: &mut dyn HostIO, mask: u64, _timeout_ms: i32, -) -> Result<(u32, i32, i32), Errno> { +) -> Result<(u32, i32, i32, i32, i32), Errno> { use wasm_posix_shared::signal::NSIG; let tid = crate::process_table::current_tid(); @@ -5263,33 +6054,17 @@ pub fn sys_sigtimedwait( if pending_in_mask != 0 { let signum = pending_in_mask.trailing_zeros() + 1; if signum < NSIG { - // Prefer directed delivery for non-main threads, same rule as - // `kernel_dequeue_signal`. - let bit = crate::signal::sig_bit(signum); - if !proc.is_main_thread(tid) { - if let Some(t) = proc.get_thread_mut(tid) { - if (t.signals.pending & bit) != 0 { - let (mut si_value, mut si_code) = (0i32, 0i32); - if let Some(pos) = - t.signals.rt_queue.iter().position(|e| e.signum == signum) - { - si_value = t.signals.rt_queue[pos].si_value; - si_code = t.signals.rt_queue[pos].si_code; - t.signals.rt_queue.remove(pos); - } - if signum >= crate::signal::SIGRTMIN { - if !t.signals.rt_queue.iter().any(|e| e.signum == signum) { - t.signals.pending &= !bit; - } - } else { - t.signals.pending &= !bit; - } - return Ok((signum, si_value, si_code)); - } - } - } - let (si_value, si_code) = proc.signals.consume_one(signum); - return Ok((signum, si_value, si_code)); + let info = proc + .consume_signal_for(tid, signum) + .ok_or(Errno::EAGAIN)?; + let (word_1, word_2) = match info.timer_id { + Some(timer_id) => ( + timer_id as i32, + proc.accept_posix_timer_notification(timer_id).unwrap_or(0), + ), + None => (proc.pid as i32, proc.uid as i32), + }; + return Ok((signum, info.si_value, info.si_code, word_1, word_2)); } } @@ -5361,10 +6136,18 @@ pub fn sys_sigaction( mask, }; + let discard_pending = crate::signal::should_discard_pending(sig, &new_handler); + if discard_pending { + proc.discard_pending_signal(sig); + } + let old = proc .signals .set_action(sig, new_action) .map_err(|_| Errno::EINVAL)?; + if crate::signal::should_discard_pending(sig, &new_handler) { + proc.clear_directed_signal(sig); + } let old_handler_val = match old.handler { SignalHandler::Default => SIG_DFL, @@ -5378,16 +6161,27 @@ pub fn sys_sigaction( /// signal() — set signal handler (legacy API, wraps sigaction semantics) /// Returns previous handler value: SIG_DFL=0, SIG_IGN=1, or function pointer pub fn sys_signal(proc: &mut Process, signum: u32, handler_val: u32) -> Result { + if signum == 0 || signum >= NSIG || signum == SIGKILL || signum == SIGSTOP { + return Err(Errno::EINVAL); + } let new_handler = match handler_val { SIG_DFL => SignalHandler::Default, SIG_IGN => SignalHandler::Ignore, ptr => SignalHandler::Handler(ptr), }; + let discard_pending = crate::signal::should_discard_pending(signum, &new_handler); + if discard_pending { + proc.discard_pending_signal(signum); + } + let old = proc .signals .set_handler(signum, new_handler) .map_err(|_| Errno::EINVAL)?; + if crate::signal::should_discard_pending(signum, &new_handler) { + proc.clear_directed_signal(signum); + } let old_val = match old { SignalHandler::Default => SIG_DFL as i32, @@ -5426,13 +6220,14 @@ pub fn sys_sigprocmask(proc: &mut Process, how: u32, set: u64) -> Result = proc.fd_table.iter().map(|(fd, _)| fd).collect(); + for fd in open_fds { let _ = sys_close(proc, host, fd); } @@ -5448,18 +6243,63 @@ pub fn sys_exit(proc: &mut Process, host: &mut dyn HostIO, status: i32) { // POSIX: all advisory locks held by the process are released on exit. let pid = proc.pid; fallback_lock_table(proc).remove_all_for_pid(pid); +} + +/// Exit normally, publishing the parent-visible status after cleanup. +pub fn sys_exit(proc: &mut Process, host: &mut dyn HostIO, status: i32) { + if matches!(proc.state, ProcessState::Exited | ProcessState::Limbo) { + return; + } + cleanup_process_for_exit(proc, host); + proc.record_normal_exit(status); +} + +/// Exit for a signal's terminating default action, publishing status after +/// cleanup completes. +pub fn sys_exit_by_signal(proc: &mut Process, host: &mut dyn HostIO, signum: u32) { + if matches!(proc.state, ProcessState::Exited | ProcessState::Limbo) { + return; + } + cleanup_process_for_exit(proc, host); + proc.record_signal_exit(signum); +} + +fn is_encoded_process_cpu_clock_id(clock_id: u32) -> bool { + // musl/Linux encode process CPU clocks as (-pid-1)*8 + 2. Real encoded + // IDs are negative clockid_t values; checking only the low three bits + // would incorrectly accept positive IDs such as 10 (produced when an + // invalid negative pid is passed to clock_getcpuclockid()). + (clock_id as i32) < 0 && (clock_id & 7) == 2 +} - proc.state = ProcessState::Exited; - proc.exit_status = status; +fn host_clock_id(clock_id: u32) -> Result { + use wasm_posix_shared::clock::*; + + match clock_id { + CLOCK_REALTIME | CLOCK_MONOTONIC | CLOCK_PROCESS_CPUTIME_ID + | CLOCK_THREAD_CPUTIME_ID | CLOCK_BOOTTIME => Ok(clock_id), + // Linux exposes coarse variants to libc consumers such as MariaDB. + // Kandelo's hosts do not maintain separate coarse clock sources, so + // preserve the clock domain while using the corresponding canonical + // source. Returning EINVAL here leaves MariaDB spinning before its + // first filesystem operation. + CLOCK_REALTIME_COARSE => Ok(CLOCK_REALTIME), + CLOCK_MONOTONIC_COARSE => Ok(CLOCK_MONOTONIC), + // clock_getcpuclockid() encodes a process clock as (-pid-1)*8 + 2. + // Kandelo does not yet account CPU time per process, so preserve the + // documented elapsed-time approximation without letting the host + // mistake an encoded ID for CLOCK_REALTIME. + id if is_encoded_process_cpu_clock_id(id) => Ok(CLOCK_PROCESS_CPUTIME_ID), + _ => Err(Errno::EINVAL), + } } -/// Get the current time from the specified clock. pub fn sys_clock_gettime( _proc: &Process, host: &mut dyn HostIO, clock_id: u32, ) -> Result { - let (sec, nsec) = host.host_clock_gettime(clock_id)?; + let (sec, nsec) = host.host_clock_gettime(host_clock_id(clock_id)?)?; Ok(WasmTimespec { tv_sec: sec, tv_nsec: nsec, @@ -5483,13 +6323,17 @@ pub fn sys_nanosleep( pub fn sys_clock_getres(_proc: &Process, clock_id: u32) -> Result { use wasm_posix_shared::clock::*; match clock_id { - CLOCK_REALTIME | CLOCK_MONOTONIC | CLOCK_PROCESS_CPUTIME_ID | CLOCK_THREAD_CPUTIME_ID => { - Ok(WasmTimespec { - tv_sec: 0, - tv_nsec: 1_000_000, - }) // 1ms - } - id if (id & 7) == 2 => { + CLOCK_REALTIME + | CLOCK_MONOTONIC + | CLOCK_PROCESS_CPUTIME_ID + | CLOCK_THREAD_CPUTIME_ID + | CLOCK_REALTIME_COARSE + | CLOCK_MONOTONIC_COARSE + | CLOCK_BOOTTIME => Ok(WasmTimespec { + tv_sec: 0, + tv_nsec: 1_000_000, + }), // 1ms + id if is_encoded_process_cpu_clock_id(id) => { // Per-process CPU clock: clock_getcpuclockid encodes as (-pid-1)*8 + 2 Ok(WasmTimespec { tv_sec: 0, @@ -5500,8 +6344,8 @@ pub fn sys_clock_getres(_proc: &Process, clock_id: u32) -> Result Result<(), Errno> { use wasm_posix_shared::clock::*; // Validate clock_id - if clock_id != CLOCK_REALTIME && clock_id != CLOCK_MONOTONIC { + if clock_id != CLOCK_REALTIME && clock_id != CLOCK_MONOTONIC && clock_id != CLOCK_BOOTTIME { return Err(Errno::EINVAL); } // Validate timespec @@ -5560,8 +6404,9 @@ pub fn sys_utimensat( let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; ofd.path.clone() } else { - resolve_at_path(proc, dirfd, path)? + resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path }; + ensure_host_mutable_namespace_path(&resolved)?; // Default: set both to current time let (atime_sec, atime_nsec, mtime_sec, mtime_nsec) = if let Some(ts) = times { @@ -5595,10 +6440,19 @@ pub fn sys_mremap( flags: u32, ) -> Result { const MREMAP_MAYMOVE: u32 = 1; + const SUPPORTED_FLAGS: u32 = MREMAP_MAYMOVE; if old_len == 0 || new_len == 0 { return Err(Errno::EINVAL); } + if flags & !SUPPORTED_FLAGS != 0 { + // The current wasm libc import ABI passes the Linux mremap syscall's + // first four arguments. MREMAP_FIXED requires the fifth new-address + // argument, and MREMAP_DONTUNMAP has Linux-specific aliasing semantics + // that the kernel does not implement. Reject unsupported flags + // explicitly instead of silently treating them as an ordinary remap. + return Err(Errno::EINVAL); + } // Page-align both sizes let aligned_old = (old_len + 0xFFFF) & !0xFFFF; @@ -5729,6 +6583,21 @@ pub fn sys_unsetenv(proc: &mut Process, name: &[u8]) -> Result<(), Errno> { Ok(()) } +/// The host's generic MAP_SHARED tracker must be restricted to descriptors +/// whose `pwrite` path reaches persistent host storage. Device mappings and +/// in-kernel regular-looking files own their state elsewhere. +pub(crate) fn fd_supports_mmap_writeback(proc: &Process, fd: i32) -> bool { + let Ok(entry) = proc.fd_table.get(fd) else { + return false; + }; + let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) else { + return false; + }; + ofd.file_type == FileType::Regular + && ofd.host_handle >= 0 + && (ofd.status_flags & O_ACCMODE) == O_RDWR +} + /// mmap -- supports anonymous, file-backed MAP_PRIVATE and MAP_SHARED mappings. /// File-backed mappings are allocated as anonymous regions; the host populates /// them from the file and (for MAP_SHARED) writes back on msync/munmap. @@ -5927,12 +6796,13 @@ pub fn sys_munmap( if addr & 0xFFFF != 0 { return Err(Errno::EINVAL); } + let aligned_len = len.checked_add(0xFFFF).ok_or(Errno::EINVAL)? & !0xFFFF; // POSIX: addresses in [addr, addr+len) must be within the valid address space. // Reject if the range overflows or extends beyond Wasm linear memory limits. - if addr.checked_add(len).is_none() { + if addr.checked_add(aligned_len).is_none() { return Err(Errno::EINVAL); } - if proc.memory.overlaps_host_reserved_region(addr, len) { + if proc.memory.overlaps_host_reserved_region(addr, aligned_len) { return Err(Errno::EINVAL); } @@ -5941,7 +6811,7 @@ pub fn sys_munmap( // munmap so the host stops reading the region before its address // space is freed. let fb_release = if let Some(b) = proc.fb_binding { - let end = addr.saturating_add(len); + let end = addr.saturating_add(aligned_len); let b_end = b.addr.saturating_add(b.len); addr <= b.addr && end >= b_end } else { @@ -5959,7 +6829,7 @@ pub fn sys_munmap( // GL cmdbuf cleanup: any munmap overlap invalidates the host's // single cmdbuf view for this pid. The GL session itself may remain // initialized, but it must mmap the cmdbuf again before submitting. - unbind_gl_cmdbufs_in_range(proc, host, addr, len); + unbind_gl_cmdbufs_in_range(proc, host, addr, aligned_len); // DRI bo cleanup: drop every binding overlapped by [addr, addr+len) // and tell the host so it stops mirroring the region before the @@ -5970,7 +6840,7 @@ pub fn sys_munmap( let pid = proc.pid as i32; let mut released: alloc::vec::Vec = alloc::vec::Vec::new(); proc.dri_bindings.retain(|b| { - if ranges_overlap(addr, len, b.addr, b.len) { + if ranges_overlap(addr, aligned_len, b.addr, b.len) { released.push(*b); false } else { @@ -5983,7 +6853,7 @@ pub fn sys_munmap( // Linux munmap succeeds (returns 0) even if no mappings overlap the range, // as long as the address is valid and page-aligned. - proc.memory.munmap(addr, len); + proc.memory.munmap(addr, aligned_len); Ok(()) } @@ -6004,6 +6874,33 @@ pub fn sys_mprotect(_proc: &Process, _addr: usize, _len: usize, _prot: u32) -> R Ok(()) } +pub(crate) fn listener_accept_wake_for_entry( + proc: &Process, + entry: &crate::fd::FdEntry, +) -> Option { + use crate::socket::SocketState; + + let ofd = proc.ofd_table.get(entry.ofd_ref.0)?; + if ofd.file_type != FileType::Socket || ofd.host_handle >= 0 { + return None; + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx)?; + if sock.state != SocketState::Listening { + return None; + } + sock.accept_wake_idx +} + +pub(crate) fn find_listener_fd_by_accept_wake( + proc: &Process, + wake_idx: u32, +) -> Option { + proc.fd_table.iter().find_map(|(fd, entry)| { + (listener_accept_wake_for_entry(proc, entry) == Some(wake_idx)).then_some(fd) + }) +} + /// Create a socket, returning the new fd. pub fn sys_socket( proc: &mut Process, @@ -6158,6 +7055,26 @@ fn parse_sockaddr_in(addr: &[u8]) -> Result<([u8; 4], u16), Errno> { )) } +fn parse_sockaddr_in6(addr: &[u8]) -> Result<([u8; 16], u16), Errno> { + use wasm_posix_shared::socket::AF_INET6; + + // struct sockaddr_in6: + // sa_family_t sin6_family (2, little-endian on wasm32) + // in_port_t sin6_port (2, network byte order) + // uint32_t sin6_flowinfo + // struct in6_addr sin6_addr + // uint32_t sin6_scope_id + if addr.len() < 28 { + return Err(Errno::EINVAL); + } + if sockaddr_family(addr)? as u32 != AF_INET6 { + return Err(Errno::EAFNOSUPPORT); + } + let mut ip = [0u8; 16]; + ip.copy_from_slice(&addr[8..24]); + Ok((ip, u16::from_be_bytes([addr[2], addr[3]]))) +} + fn write_sockaddr_in(buf: &mut [u8], addr: [u8; 4], port: u16) -> usize { let mut sa = [0u8; 16]; sa[0] = 2; // AF_INET, little-endian @@ -6174,10 +7091,85 @@ fn write_sockaddr_in(buf: &mut [u8], addr: [u8; 4], port: u16) -> usize { 16 } +fn write_sockaddr_in6(buf: &mut [u8], addr: [u8; 16], port: u16) -> usize { + let mut sa = [0u8; 28]; + sa[0] = 10; // AF_INET6, little-endian + sa[1] = 0; + let port_be = port.to_be_bytes(); + sa[2] = port_be[0]; + sa[3] = port_be[1]; + // flowinfo remains zero + sa[8..24].copy_from_slice(&addr); + // scope_id remains zero + let n = buf.len().min(28); + buf[..n].copy_from_slice(&sa[..n]); + 28 +} + fn is_loopback_addr(addr: [u8; 4]) -> bool { addr[0] == 127 } +fn is_loopback_addr6(addr: [u8; 16]) -> bool { + addr == [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] +} + +fn is_unspecified_addr6(addr: [u8; 16]) -> bool { + addr == [0; 16] +} + +fn ipv4_mapped_addr6(addr: [u8; 4]) -> [u8; 16] { + let mut mapped = [0u8; 16]; + mapped[10] = 0xff; + mapped[11] = 0xff; + mapped[12..16].copy_from_slice(&addr); + mapped +} + +fn ipv6_v6only(sock: &crate::socket::SocketInfo) -> bool { + let value = sock + .get_option( + wasm_posix_shared::socket::IPPROTO_IPV6, + wasm_posix_shared::socket::IPV6_V6ONLY, + ) + // Dual-stack datagram routing is not yet exposed; report that honest + // boundary as V6ONLY while stream sockets retain Linux's dual-stack + // default. + .unwrap_or(u32::from( + sock.sock_type == crate::socket::SocketType::Dgram, + )); + value != 0 +} + +fn bind_device_allows_ipv4( + sock: &crate::socket::SocketInfo, + addr: [u8; 4], + binding: bool, +) -> bool { + match sock.bind_device.as_deref() { + None => true, + Some(b"lo") => { + addr == [0; 4] + || is_loopback_addr(addr) + || (!binding && is_ipv4_multicast_addr(addr)) + } + Some(b"eth0") => addr == [0; 4] || !is_loopback_addr(addr), + Some(_) => false, + } +} + +fn bind_device_allows_ipv6( + sock: &crate::socket::SocketInfo, + addr: [u8; 16], +) -> bool { + match sock.bind_device.as_deref() { + None => true, + Some(b"lo") => is_unspecified_addr6(addr) || is_loopback_addr6(addr), + // Kandelo does not expose an external IPv6 interface today. + Some(b"eth0") | Some(_) => false, + } +} + fn is_supported_udp_bind_addr(addr: [u8; 4]) -> bool { addr == [0, 0, 0, 0] || is_loopback_addr(addr) @@ -6186,13 +7178,31 @@ fn is_supported_udp_bind_addr(addr: [u8; 4]) -> bool { } fn is_supported_udp_route_addr(addr: [u8; 4]) -> bool { - is_loopback_addr(addr) || is_virtual_network_addr(addr) + addr == [0, 0, 0, 0] + || is_loopback_addr(addr) + || is_virtual_network_addr(addr) + || is_ipv4_multicast_addr(addr) } fn is_virtual_network_addr(addr: [u8; 4]) -> bool { addr[0] == 10 && addr[1] == 88 } +fn is_ipv4_multicast_addr(addr: [u8; 4]) -> bool { + (224..=239).contains(&addr[0]) +} + +fn udp_canonical_dst_addr(dst_addr: [u8; 4]) -> [u8; 4] { + // Linux treats UDP connect/sendto to INADDR_ANY as a route to local + // loopback (getpeername() reports 127.0.0.1). Preserve that generic + // socket behavior instead of reporting ENETUNREACH. + if dst_addr == [0, 0, 0, 0] { + [127, 0, 0, 1] + } else { + dst_addr + } +} + fn udp_route_local_addr(dst_addr: [u8; 4]) -> [u8; 4] { if is_loopback_addr(dst_addr) { [127, 0, 0, 1] @@ -6201,6 +7211,75 @@ fn udp_route_local_addr(dst_addr: [u8; 4]) -> [u8; 4] { } } +#[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))] +pub(crate) fn ipv4_multicast_interface_from_index(ifindex: u32) -> Result<[u8; 4], Errno> { + match ifindex { + 0 => Ok([0, 0, 0, 0]), + // Kandelo exposes a Linux-like loopback interface as index 1. + 1 => Ok([127, 0, 0, 1]), + _ => Err(Errno::ENODEV), + } +} + +/// Resolve musl's wasm32/wasm64 `group_req` and `group_source_req` +/// sockaddr_storage offsets from the option buffer's canonical size (or, +/// for oversized buffers, from unambiguous embedded AF_INET families). +pub(crate) fn multicast_group_request_offsets( + buf: &[u8], + with_source: bool, +) -> Result<(usize, Option), Errno> { + use wasm_posix_shared::socket::AF_INET; + + const GROUP_REQ_WASM32_SIZE: usize = 132; + const GROUP_REQ_WASM64_SIZE: usize = 136; + const GROUP_SOURCE_REQ_WASM32_SIZE: usize = 260; + const GROUP_SOURCE_REQ_WASM64_SIZE: usize = 264; + + let (size32, size64, source32, source64) = if with_source { + ( + GROUP_SOURCE_REQ_WASM32_SIZE, + GROUP_SOURCE_REQ_WASM64_SIZE, + Some(132), + Some(136), + ) + } else { + (GROUP_REQ_WASM32_SIZE, GROUP_REQ_WASM64_SIZE, None, None) + }; + if buf.len() < size32 { + return Err(Errno::EINVAL); + } + if buf.len() < size64 { + return Ok((4, source32)); + } + if buf.len() == size64 { + return Ok((8, source64)); + } + + let family_is_inet = |offset: usize| { + buf.get(offset..offset + 2) + .map(|family| u16::from_le_bytes([family[0], family[1]]) as u32 == AF_INET) + .unwrap_or(false) + }; + let wasm32 = family_is_inet(4) && source32.map(|o| family_is_inet(o)).unwrap_or(true); + let wasm64 = family_is_inet(8) && source64.map(|o| family_is_inet(o)).unwrap_or(true); + match (wasm32, wasm64) { + (true, false) => Ok((4, source32)), + (false, true) => Ok((8, source64)), + (true, true) => Err(Errno::EINVAL), + (false, false) => Ok((8, source64)), + } +} + +fn ipv4_multicast_interface_matches(interface_addr: [u8; 4], ingress_interface: [u8; 4]) -> bool { + if interface_addr == [0, 0, 0, 0] { + true + } else if is_loopback_addr(interface_addr) { + is_loopback_addr(ingress_interface) + } else { + interface_addr == ingress_interface + } +} + fn udp_reuse_addr(sock: &crate::socket::SocketInfo) -> bool { use wasm_posix_shared::socket::{SO_REUSEADDR, SOL_SOCKET}; @@ -6247,6 +7326,13 @@ fn udp_bind_socket( if !is_supported_udp_bind_addr(addr) { return Err(Errno::EADDRNOTAVAIL); } + if !bind_device_allows_ipv4( + proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?, + addr, + true, + ) { + return Err(Errno::EADDRNOTAVAIL); + } let reuse_addr = { let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; @@ -6300,11 +7386,31 @@ fn udp_socket_accepts_datagram( true } -fn udp_queue_datagram(sock: &mut crate::socket::SocketInfo, datagram: crate::socket::Datagram) { +fn udp_queue_datagram( + sock: &mut crate::socket::SocketInfo, + make_datagram: impl FnOnce() -> crate::socket::Datagram, +) { + // This is a fixed internal queue limit; SO_RCVBUF is currently advisory. + // UDP is unreliable, so a full receive queue drops the incoming datagram + // while preserving the order of datagrams that were already accepted. + if sock.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT { + return; + } + sock.dgram_queue.push(make_datagram()); +} + +fn unix_queue_datagram( + sock: &mut crate::socket::SocketInfo, + make_datagram: impl FnOnce() -> crate::socket::Datagram, +) -> Result<(), Errno> { + // AF_UNIX datagrams are reliable. EAGAIN enters the host's ordinary + // blocking-write retry path, while O_NONBLOCK or MSG_DONTWAIT exposes it + // directly to the caller. if sock.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT { - sock.dgram_queue.remove(0); + return Err(Errno::EAGAIN); } - sock.dgram_queue.push(datagram); + sock.dgram_queue.push(make_datagram()); + Ok(()) } fn udp_take_socket_error(proc: &mut Process, sock_idx: usize) -> Result<(), Errno> { @@ -6322,46 +7428,214 @@ fn udp_take_socket_error(proc: &mut Process, sock_idx: usize) -> Result<(), Errn Err(Errno::from_u32(err).unwrap_or(Errno::EIO)) } -fn udp_purge_unaccepted_datagrams(proc: &mut Process, sock_idx: usize) -> Result<(), Errno> { - let (peer_addr, peer_port, connected) = { - let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - ( - sock.peer_addr, - sock.peer_port, - sock.state == crate::socket::SocketState::Connected, - ) - }; - if !connected { - return Ok(()); +fn ipv4_multicast_membership_mut( + sock: &mut crate::socket::SocketInfo, + group: [u8; 4], + interface_addr: [u8; 4], +) -> &mut crate::socket::Ipv4MulticastMembership { + if let Some(idx) = sock + .ipv4_multicast_memberships + .iter() + .position(|m| m.group == group && m.interface_addr == interface_addr) + { + return &mut sock.ipv4_multicast_memberships[idx]; + } + sock.ipv4_multicast_memberships + .push(crate::socket::Ipv4MulticastMembership { + group, + interface_addr, + any_source: false, + blocked_sources: Vec::new(), + included_sources: Vec::new(), + }); + sock.ipv4_multicast_memberships + .last_mut() + .expect("membership was just pushed") +} + +fn ipv4_multicast_leave_if_empty(sock: &mut crate::socket::SocketInfo, idx: usize) { + if let Some(m) = sock.ipv4_multicast_memberships.get(idx) { + if !m.any_source && m.included_sources.is_empty() { + sock.ipv4_multicast_memberships.remove(idx); + } } - let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - sock.dgram_queue - .retain(|d| d.src_addr == peer_addr && d.src_port == peer_port); - Ok(()) } -fn udp_send_datagram( +/// Apply an IPv4 multicast membership/source-filter socket option. +/// +/// Kandelo models the POSIX/Linux observable contract for local UDP +/// multicast: joining a group on loopback lets datagrams sent to that group +/// and interface be received by sockets bound to the destination port, source +/// filters suppress or include sources, and sends to groups with no local +/// listeners still succeed like UDP datagrams. +pub fn sys_setsockopt_ipv4_multicast( proc: &mut Process, - host: &mut dyn HostIO, - sock_idx: usize, - buf: &[u8], - dst_addr: [u8; 4], - dst_port: u16, -) -> Result { - use crate::socket::{Datagram, SocketState}; + fd: i32, + optname: u32, + group: [u8; 4], + interface_addr: [u8; 4], + source: Option<[u8; 4]>, +) -> Result<(), Errno> { + use crate::socket::{SocketDomain, SocketType}; + use wasm_posix_shared::socket::*; - let (state, shut_wr) = { - let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - (sock.state, sock.shut_wr) - }; - if shut_wr { - return Err(Errno::EPIPE); - } - if state == SocketState::Connected { - udp_take_socket_error(proc, sock_idx)?; + if !is_ipv4_multicast_addr(group) { + return Err(Errno::EINVAL); } - let auto_bind_addr = if state == SocketState::Connected { - udp_route_local_addr(dst_addr) + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + if sock.domain != SocketDomain::Inet || sock.sock_type != SocketType::Dgram { + return Err(Errno::ENOPROTOOPT); + } + + match optname { + IP_ADD_MEMBERSHIP | MCAST_JOIN_GROUP => { + let membership = ipv4_multicast_membership_mut(sock, group, interface_addr); + membership.any_source = true; + sock.set_option(IPPROTO_IP, optname, 1); + Ok(()) + } + IP_DROP_MEMBERSHIP | MCAST_LEAVE_GROUP => { + sock.ipv4_multicast_memberships + .retain(|m| !(m.group == group && m.interface_addr == interface_addr)); + sock.set_option(IPPROTO_IP, optname, 1); + Ok(()) + } + IP_BLOCK_SOURCE | MCAST_BLOCK_SOURCE => { + let source = source.ok_or(Errno::EINVAL)?; + let membership = ipv4_multicast_membership_mut(sock, group, interface_addr); + if !membership.blocked_sources.contains(&source) { + membership.blocked_sources.push(source); + } + sock.set_option(IPPROTO_IP, optname, 1); + Ok(()) + } + IP_UNBLOCK_SOURCE | MCAST_UNBLOCK_SOURCE => { + let source = source.ok_or(Errno::EINVAL)?; + if let Some(membership) = sock + .ipv4_multicast_memberships + .iter_mut() + .find(|m| m.group == group && m.interface_addr == interface_addr) + { + membership.blocked_sources.retain(|s| *s != source); + } + sock.set_option(IPPROTO_IP, optname, 1); + Ok(()) + } + IP_ADD_SOURCE_MEMBERSHIP | MCAST_JOIN_SOURCE_GROUP => { + let source = source.ok_or(Errno::EINVAL)?; + let membership = ipv4_multicast_membership_mut(sock, group, interface_addr); + if !membership.included_sources.contains(&source) { + membership.included_sources.push(source); + } + sock.set_option(IPPROTO_IP, optname, 1); + Ok(()) + } + IP_DROP_SOURCE_MEMBERSHIP | MCAST_LEAVE_SOURCE_GROUP => { + let source = source.ok_or(Errno::EINVAL)?; + if let Some(idx) = sock + .ipv4_multicast_memberships + .iter() + .position(|m| m.group == group && m.interface_addr == interface_addr) + { + sock.ipv4_multicast_memberships[idx] + .included_sources + .retain(|s| *s != source); + ipv4_multicast_leave_if_empty(sock, idx); + } + sock.set_option(IPPROTO_IP, optname, 1); + Ok(()) + } + _ => Err(Errno::ENOPROTOOPT), + } +} + +fn udp_socket_accepts_multicast_datagram( + sock: &crate::socket::SocketInfo, + group: [u8; 4], + src_addr: [u8; 4], + src_port: u16, + ingress_interface: [u8; 4], +) -> bool { + if !udp_socket_accepts_datagram(sock, src_addr, src_port) { + return false; + } + sock.ipv4_multicast_memberships.iter().any(|membership| { + membership.group == group + && ipv4_multicast_interface_matches(membership.interface_addr, ingress_interface) + && ((membership.any_source && !membership.blocked_sources.contains(&src_addr)) + || membership.included_sources.contains(&src_addr)) + }) +} + +fn udp_purge_unaccepted_datagrams(proc: &mut Process, sock_idx: usize) -> Result<(), Errno> { + let (peer_addr, peer_port, connected) = { + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + ( + sock.peer_addr, + sock.peer_port, + sock.state == crate::socket::SocketState::Connected, + ) + }; + if !connected { + return Ok(()); + } + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + sock.dgram_queue + .retain(|d| d.src_addr == peer_addr && d.src_port == peer_port); + Ok(()) +} + +fn udp_send_datagram( + proc: &mut Process, + host: &mut dyn HostIO, + sock_idx: usize, + buf: &[u8], + dst_addr: [u8; 4], + dst_port: u16, +) -> Result { + use crate::socket::{Datagram, SocketState}; + + let dst_addr = udp_canonical_dst_addr(dst_addr); + let (state, shut_wr) = { + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + (sock.state, sock.shut_wr) + }; + if shut_wr { + return Err(Errno::EPIPE); + } + if state == SocketState::Connected { + udp_take_socket_error(proc, sock_idx)?; + } + if dst_addr == [255, 255, 255, 255] { + use wasm_posix_shared::socket::{SO_BROADCAST, SOL_SOCKET}; + + let broadcast_enabled = proc + .sockets + .get(sock_idx) + .ok_or(Errno::EBADF)? + .get_option(SOL_SOCKET, SO_BROADCAST) + .unwrap_or(0) + != 0; + if !broadcast_enabled { + return Err(Errno::EACCES); + } + } + if !bind_device_allows_ipv4( + proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?, + dst_addr, + false, + ) { + return Err(Errno::ENETUNREACH); + } + let auto_bind_addr = if state == SocketState::Connected { + udp_route_local_addr(dst_addr) } else { [0, 0, 0, 0] }; @@ -6371,6 +7645,84 @@ fn udp_send_datagram( let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; (sock.bind_addr, sock.bind_port) }; + let mut src_addr = if src_addr == [0, 0, 0, 0] && is_loopback_addr(dst_addr) { + udp_route_local_addr(dst_addr) + } else { + src_addr + }; + let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); + if is_ipv4_multicast_addr(dst_addr) { + use wasm_posix_shared::socket::{IP_MULTICAST_IF, IP_MULTICAST_LOOP, IPPROTO_IP}; + + let (loop_enabled, outgoing_interface) = { + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + let loop_enabled = sock + .get_option(IPPROTO_IP, IP_MULTICAST_LOOP) + .unwrap_or(1) + != 0; + let configured = sock + .get_option(IPPROTO_IP, IP_MULTICAST_IF) + .unwrap_or(0) + .to_le_bytes(); + let outgoing_interface = if configured != [0; 4] { + configured + } else if sock.bind_device.as_deref() == Some(b"lo") { + [127, 0, 0, 1] + } else if is_loopback_addr(sock.bind_addr) + || is_virtual_network_addr(sock.bind_addr) + { + sock.bind_addr + } else { + [0; 4] + }; + (loop_enabled, outgoing_interface) + }; + if src_addr == [0; 4] && outgoing_interface != [0; 4] { + src_addr = outgoing_interface; + } + if !loop_enabled { + return Ok(buf.len()); + } + let endpoints = crate::socket::udp_lookup(dst_addr, dst_port); + for endpoint in endpoints { + if endpoint.pid != proc.pid { + continue; + } + let accepts = proc + .sockets + .get(endpoint.sock_idx) + .map(|sock| { + udp_socket_accepts_multicast_datagram( + sock, + dst_addr, + src_addr, + src_port, + outgoing_interface, + ) + }) + .unwrap_or(false); + if !accepts { + continue; + } + if let Some(target) = proc.sockets.get_mut(endpoint.sock_idx) { + udp_queue_datagram(target, || Datagram { + data: buf.to_vec(), + src_addr, + src_addr6: [0; 16], + dst_addr, + dst_addr6: [0; 16], + src_port, + src_sock_idx: Some(sock_idx), + ipv6_tclass: 0, + src_pid, + src_uid, + src_gid, + ancillary_fds: Vec::new(), + }); + } + } + return Ok(buf.len()); + } if !is_loopback_addr(dst_addr) { return match host.host_udp_send(&src_addr, src_port, &dst_addr, dst_port, buf) { Ok(n) => Ok(n), @@ -6384,12 +7736,6 @@ fn udp_send_datagram( Err(e) => Err(e), }; } - let datagram = Datagram { - data: buf.to_vec(), - src_addr, - src_port, - }; - let mut delivered = false; let endpoints = crate::socket::udp_lookup(dst_addr, dst_port); for endpoint in endpoints { @@ -6405,7 +7751,20 @@ fn udp_send_datagram( continue; } if let Some(target) = proc.sockets.get_mut(endpoint.sock_idx) { - udp_queue_datagram(target, datagram.clone()); + udp_queue_datagram(target, || Datagram { + data: buf.to_vec(), + src_addr, + src_addr6: [0; 16], + dst_addr, + dst_addr6: [0; 16], + src_port, + src_sock_idx: Some(sock_idx), + ipv6_tclass: 0, + src_pid, + src_uid, + src_gid, + ancillary_fds: Vec::new(), + }); delivered = true; break; } @@ -6420,115 +7779,407 @@ fn udp_send_datagram( Ok(buf.len()) } -pub fn inject_udp_datagram_into( +fn unix_dgram_send_to_sock( proc: &mut Process, - dst_addr: [u8; 4], - dst_port: u16, - src_addr: [u8; 4], - src_port: u16, - data: &[u8], -) -> i32 { + src_sock_idx: usize, + dst_sock_idx: usize, + buf: &[u8], +) -> Result { use crate::socket::Datagram; - let datagram = Datagram { - data: data.to_vec(), - src_addr, - src_port, - }; - let endpoints = crate::socket::udp_lookup(dst_addr, dst_port); - for endpoint in endpoints { - if endpoint.pid != proc.pid { - continue; - } - let accepts = proc - .sockets - .get(endpoint.sock_idx) - .map(|sock| udp_socket_accepts_datagram(sock, src_addr, src_port)) - .unwrap_or(false); - if !accepts { - continue; - } - if let Some(target) = proc.sockets.get_mut(endpoint.sock_idx) { - udp_queue_datagram(target, datagram); - return 0; - } + let shut_wr = proc + .sockets + .get(src_sock_idx) + .ok_or(Errno::EBADF)? + .shut_wr; + if shut_wr { + return Err(Errno::EPIPE); } - -(Errno::ECONNREFUSED as i32) + + let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); + let target = proc.sockets.get_mut(dst_sock_idx).ok_or(Errno::ECONNREFUSED)?; + if target.domain != crate::socket::SocketDomain::Unix + || target.sock_type != crate::socket::SocketType::Dgram + || !matches!( + target.state, + crate::socket::SocketState::Bound | crate::socket::SocketState::Connected + ) + { + return Err(Errno::ECONNREFUSED); + } + if !unix_dgram_target_accepts_sender(target, src_sock_idx) { + return Err(Errno::EPERM); + } + // A read-shut AF_UNIX datagram peer cannot accept another reliable + // message. Report the broken association before constructing a payload; + // the send wrapper supplies SIGPIPE unless MSG_NOSIGNAL was requested. + if target.shut_rd { + return Err(Errno::EPIPE); + } + unix_queue_datagram(target, || Datagram { + data: buf.to_vec(), + src_addr: [0; 4], + src_addr6: [0; 16], + dst_addr: [0; 4], + dst_addr6: [0; 16], + src_port: 0, + src_sock_idx: Some(src_sock_idx), + ipv6_tclass: 0, + src_pid, + src_uid, + src_gid, + ancillary_fds: Vec::new(), + })?; + Ok(buf.len()) } -/// getsockname -- get local socket address. -/// -/// For AF_INET sockets, writes a full 16-byte sockaddr_in: -/// family(2 LE) + port(2 BE) + addr(4) + zero(8) -/// For AF_UNIX sockets, writes AF_UNIX (family=1) with empty path. -/// Returns the number of bytes written. -pub fn sys_getsockname(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { - use crate::socket::SocketDomain; +fn finish_datagram_send( + proc: &mut Process, + flags: u32, + result: Result, +) -> Result { + if matches!(result, Err(Errno::EPIPE)) + && flags & wasm_posix_shared::socket::MSG_NOSIGNAL == 0 + { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + result +} - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; - if ofd.file_type != FileType::Socket { - return Err(Errno::ENOTSOCK); +fn udp6_bind_socket( + proc: &mut Process, + sock_idx: usize, + addr: [u8; 16], + port: u16, +) -> Result<(), Errno> { + use crate::socket::SocketState; + + if !(is_loopback_addr6(addr) || is_unspecified_addr6(addr)) { + return Err(Errno::EADDRNOTAVAIL); + } + if !bind_device_allows_ipv6(proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?, addr) { + return Err(Errno::EADDRNOTAVAIL); } - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - match sock.domain { - SocketDomain::Inet => Ok(write_sockaddr_in(buf, sock.bind_addr, sock.bind_port)), - SocketDomain::Inet6 => { - if buf.len() >= 2 { - buf[0] = 10; // AF_INET6 - buf[1] = 0; - } - Ok(2) + let reuse_addr = proc + .sockets + .get(sock_idx) + .and_then(|sock| { + sock.get_option( + wasm_posix_shared::socket::SOL_SOCKET, + wasm_posix_shared::socket::SO_REUSEADDR, + ) + }) + .unwrap_or(0) + != 0; + let assigned_port = if port == 0 { + if proc.next_ephemeral_port < 49152 { + proc.next_ephemeral_port = 49152; } - SocketDomain::Unix => { - if let Some(ref path) = sock.bind_path { - // sockaddr_un: family(2) + path (null-terminated) - let total_len = 2 + path.len() + 1; // +1 for null terminator - let n = buf.len().min(total_len); - if n >= 1 { - buf[0] = 1; - } // AF_UNIX low byte - if n >= 2 { - buf[1] = 0; - } // AF_UNIX high byte - let path_copy = n.saturating_sub(2).min(path.len()); - if path_copy > 0 { - buf[2..2 + path_copy].copy_from_slice(&path[..path_copy]); - } - // Null terminate if room - if n > 2 + path_copy { - buf[2 + path_copy] = 0; - } - Ok(total_len) - } else { - // Unbound AF_UNIX socket — return just the family - if buf.len() >= 2 { - buf[0] = 1; // AF_UNIX - buf[1] = 0; - } - Ok(2) + let start = proc.next_ephemeral_port; + loop { + let candidate = proc.next_ephemeral_port; + bump_ephemeral_port(proc); + if crate::socket::udp6_can_bind(proc.pid, sock_idx, addr, candidate, reuse_addr) { + break candidate; + } + if proc.next_ephemeral_port == start { + return Err(Errno::EADDRINUSE); } } + } else { + port + }; + crate::socket::udp6_register(proc.pid, sock_idx, addr, assigned_port, reuse_addr)?; + + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + sock.bind_addr6 = addr; + sock.bind_port = assigned_port; + if sock.state == SocketState::Unbound { + sock.state = SocketState::Bound; } + Ok(()) } -/// getpeername -- get remote socket address. -/// -/// For AF_UNIX socketpairs, returns AF_UNIX family. -/// For AF_INET/AF_INET6 sockets, returns the corresponding family. -pub fn sys_getpeername(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { - use crate::socket::{SocketDomain, SocketState}; - - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; - if ofd.file_type != FileType::Socket { - return Err(Errno::ENOTSOCK); +fn udp6_ensure_bound( + proc: &mut Process, + sock_idx: usize, + addr: [u8; 16], +) -> Result<(), Errno> { + let already_bound = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?.bind_port != 0; + if already_bound { + return Ok(()); } - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - // Must be connected + udp6_bind_socket(proc, sock_idx, addr, 0) +} + +fn udp6_socket_accepts_datagram( + sock: &crate::socket::SocketInfo, + src_addr: [u8; 16], + src_port: u16, +) -> bool { + use crate::socket::SocketState; + + if sock.state == SocketState::Connected { + return sock.peer_addr6 == src_addr && sock.peer_port == src_port; + } + true +} + +fn unix_dgram_target_accepts_sender( + target: &crate::socket::SocketInfo, + src_sock_idx: usize, +) -> bool { + use crate::socket::SocketState; + + target.state != SocketState::Connected || target.peer_idx == Some(src_sock_idx) +} + +fn unix_dgram_matches_peer( + owner_pid: u32, + peer_idx: usize, + datagram: &crate::socket::Datagram, +) -> bool { + datagram.src_pid == owner_pid && datagram.src_sock_idx == Some(peer_idx) +} + +/// Whether a queued datagram is visible through this socket's connected-peer +/// filter. Keep recv and poll on the same predicate so readiness cannot claim +/// data that the following recv would reject. +fn dgram_matches_connected_peer( + sock: &crate::socket::SocketInfo, + owner_pid: u32, + datagram: &crate::socket::Datagram, +) -> bool { + use crate::socket::{SocketDomain, SocketState}; + + if sock.state != SocketState::Connected { + return true; + } + match sock.domain { + SocketDomain::Inet => { + datagram.src_addr == sock.peer_addr && datagram.src_port == sock.peer_port + } + SocketDomain::Inet6 => { + datagram.src_addr6 == sock.peer_addr6 && datagram.src_port == sock.peer_port + } + SocketDomain::Unix => sock + .peer_idx + .is_some_and(|peer| unix_dgram_matches_peer(owner_pid, peer, datagram)), + } +} + +fn udp6_send_datagram( + proc: &mut Process, + sock_idx: usize, + buf: &[u8], + dst_addr: [u8; 16], + dst_port: u16, +) -> Result { + use crate::socket::{Datagram, SocketDomain, SocketState, SocketType}; + + let dst_addr = if is_unspecified_addr6(dst_addr) { + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + } else { + dst_addr + }; + if !is_loopback_addr6(dst_addr) { + return Err(Errno::ENETUNREACH); + } + if !bind_device_allows_ipv6(proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?, dst_addr) { + return Err(Errno::ENETUNREACH); + } + + let (state, shut_wr) = { + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + (sock.state, sock.shut_wr) + }; + if shut_wr { + return Err(Errno::EPIPE); + } + if state == SocketState::Connected { + udp_take_socket_error(proc, sock_idx)?; + } + + let loopback = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + let auto_bind_addr = if state == SocketState::Connected { + loopback + } else { + [0; 16] + }; + udp6_ensure_bound(proc, sock_idx, auto_bind_addr)?; + + let (src_addr, src_port) = { + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + (sock.bind_addr6, sock.bind_port) + }; + let src_addr = if is_unspecified_addr6(src_addr) { + loopback + } else { + src_addr + }; + let (src_pid, src_uid, src_gid) = (proc.pid, proc.uid, proc.gid); + + let mut delivered = false; + let sock_count = proc.sockets.len(); + for idx in 0..sock_count { + let accepts = proc + .sockets + .get(idx) + .map(|sock| { + sock.domain == SocketDomain::Inet6 + && sock.sock_type == SocketType::Dgram + && sock.bind_port == dst_port + && (is_unspecified_addr6(sock.bind_addr6) || sock.bind_addr6 == dst_addr) + && udp6_socket_accepts_datagram(sock, src_addr, src_port) + }) + .unwrap_or(false); + if !accepts { + continue; + } + if let Some(target) = proc.sockets.get_mut(idx) { + udp_queue_datagram(target, || Datagram { + data: buf.to_vec(), + src_addr: [0; 4], + src_addr6: src_addr, + dst_addr: [0; 4], + dst_addr6: dst_addr, + src_port, + src_sock_idx: Some(sock_idx), + ipv6_tclass: 0, + src_pid, + src_uid, + src_gid, + ancillary_fds: Vec::new(), + }); + delivered = true; + break; + } + } + + if !delivered && state == SocketState::Connected { + if let Some(sock) = proc.sockets.get_mut(sock_idx) { + sock.connect_error = Errno::ECONNREFUSED as u32; + } + } + + Ok(buf.len()) +} + +pub fn inject_udp_datagram_into( + proc: &mut Process, + dst_addr: [u8; 4], + dst_port: u16, + src_addr: [u8; 4], + src_port: u16, + data: &[u8], +) -> i32 { + use crate::socket::Datagram; + + let endpoints = crate::socket::udp_lookup(dst_addr, dst_port); + for endpoint in endpoints { + if endpoint.pid != proc.pid { + continue; + } + let accepts = proc + .sockets + .get(endpoint.sock_idx) + .map(|sock| udp_socket_accepts_datagram(sock, src_addr, src_port)) + .unwrap_or(false); + if !accepts { + continue; + } + if let Some(target) = proc.sockets.get_mut(endpoint.sock_idx) { + udp_queue_datagram(target, || Datagram { + data: data.to_vec(), + src_addr, + src_addr6: [0; 16], + dst_addr, + dst_addr6: [0; 16], + src_port, + src_sock_idx: None, + ipv6_tclass: 0, + src_pid: 0, + src_uid: 0, + src_gid: 0, + ancillary_fds: Vec::new(), + }); + return 0; + } + } + -(Errno::ECONNREFUSED as i32) +} + +/// getsockname -- get local socket address. +/// +/// For AF_INET sockets, writes a full 16-byte sockaddr_in: +/// family(2 LE) + port(2 BE) + addr(4) + zero(8) +/// For AF_UNIX sockets, writes AF_UNIX (family=1) with empty path. +/// Returns the number of bytes written. +pub fn sys_getsockname(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { + use crate::socket::SocketDomain; + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + + match sock.domain { + SocketDomain::Inet => Ok(write_sockaddr_in(buf, sock.bind_addr, sock.bind_port)), + SocketDomain::Inet6 => Ok(write_sockaddr_in6(buf, sock.bind_addr6, sock.bind_port)), + SocketDomain::Unix => { + if let Some(ref path) = sock.bind_path { + // sockaddr_un: family(2) + path. Filesystem paths are + // null-terminated; Linux abstract namespace paths start with + // NUL and use the addrlen as their length (no terminator). + let abstract_unix = path.first().copied() == Some(0); + let total_len = 2 + path.len() + if abstract_unix { 0 } else { 1 }; + let n = buf.len().min(total_len); + if n >= 1 { + buf[0] = 1; + } // AF_UNIX low byte + if n >= 2 { + buf[1] = 0; + } // AF_UNIX high byte + let path_copy = n.saturating_sub(2).min(path.len()); + if path_copy > 0 { + buf[2..2 + path_copy].copy_from_slice(&path[..path_copy]); + } + // Null terminate filesystem paths if room. + if !abstract_unix && n > 2 + path_copy { + buf[2 + path_copy] = 0; + } + Ok(total_len) + } else { + // Unbound AF_UNIX socket — return just the family + if buf.len() >= 2 { + buf[0] = 1; // AF_UNIX + buf[1] = 0; + } + Ok(2) + } + } + } +} + +/// getpeername -- get remote socket address. +/// +/// For AF_UNIX socketpairs, returns AF_UNIX family. +/// For AF_INET/AF_INET6 sockets, returns the corresponding family. +pub fn sys_getpeername(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { + use crate::socket::{SocketDomain, SocketState}; + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + // Must be connected match sock.domain { SocketDomain::Inet | SocketDomain::Inet6 => { if sock.state != SocketState::Connected { @@ -6543,13 +8194,7 @@ pub fn sys_getpeername(proc: &Process, fd: i32, buf: &mut [u8]) -> Result Ok(write_sockaddr_in(buf, sock.peer_addr, sock.peer_port)), - SocketDomain::Inet6 => { - if buf.len() >= 2 { - buf[0] = 10; // AF_INET6 - buf[1] = 0; - } - Ok(2) - } + SocketDomain::Inet6 => Ok(write_sockaddr_in6(buf, sock.peer_addr6, sock.peer_port)), SocketDomain::Unix => { if buf.len() >= 2 { buf[0] = 1; // AF_UNIX @@ -6562,7 +8207,8 @@ pub fn sys_getpeername(proc: &Process, fd: i32, buf: &mut [u8]) -> Result { - sock.shut_rd = true; - } - SHUT_WR => { - sock.shut_wr = true; - if let Some(send_idx) = sock.send_buf_idx { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(send_idx) }; - if let Some(pipe) = pipe { - pipe.close_write_end(); - } - } - } - SHUT_RDWR => { - sock.shut_rd = true; - sock.shut_wr = true; - if let Some(net_handle) = sock.host_net_handle { - let _ = host.host_net_close(net_handle); + // Taking the resource indexes makes shutdown idempotent and prevents a + // later close, process exit, fork, or SCM_RIGHTS transfer from dropping or + // resurrecting the same pipe reference. Explicit SHUT_RD is a hard receive + // refusal; only normal close uses TCP's orderly orphaned-receive state. + let (send_idx, recv_idx, net_handle, datagram_send_state_changed) = { + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + let is_unix_dgram = sock.domain == crate::socket::SocketDomain::Unix + && sock.sock_type == crate::socket::SocketType::Dgram; + let was_shut_rd = sock.shut_rd; + let was_shut_wr = sock.shut_wr; + match how { + SHUT_RD => { + sock.shut_rd = true; + ( + None, + sock.recv_buf_idx.take(), + None, + is_unix_dgram && !was_shut_rd, + ) } - if let Some(send_idx) = sock.send_buf_idx { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(send_idx) }; - if let Some(pipe) = pipe { - pipe.close_write_end(); - } + SHUT_WR => { + sock.shut_wr = true; + ( + sock.send_buf_idx.take(), + None, + None, + is_unix_dgram && !was_shut_wr, + ) } - if let Some(recv_idx) = sock.recv_buf_idx { - let pipe = unsafe { crate::pipe::global_pipe_table().get_mut(recv_idx) }; - if let Some(pipe) = pipe { - pipe.close_read_end(); - } + SHUT_RDWR => { + sock.shut_rd = true; + sock.shut_wr = true; + ( + sock.send_buf_idx.take(), + sock.recv_buf_idx.take(), + sock.host_net_handle.take(), + is_unix_dgram && (!was_shut_rd || !was_shut_wr), + ) } + _ => return Err(Errno::EINVAL), } - _ => return Err(Errno::EINVAL), + }; + + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + if let Some(send_idx) = send_idx { + if let Some(pipe) = pipe_table.get_mut(send_idx) { + pipe.close_write_end(); + // Wake a local writer that was blocked before shut_wr became true. + crate::wakeup::push(send_idx as u32, crate::wakeup::WAKE_WRITABLE); + } + pipe_table.free_if_closed(send_idx); + } + if let Some(recv_idx) = recv_idx { + if let Some(pipe) = pipe_table.get_mut(recv_idx) { + pipe.close_read_end(); + // Wake a local reader that was blocked before shut_rd became true. + crate::wakeup::push(recv_idx as u32, crate::wakeup::WAKE_READABLE); + } + pipe_table.free_if_closed(recv_idx); + } + if let Some(net_handle) = net_handle { + if crate::socket::host_net_handle_close_ref(net_handle) { + let _ = host.host_net_close(net_handle); + } + } + // AF_UNIX datagram writers and readiness waiters are not backed by a + // targetable pipe. A read-side or write-side shutdown changes whether the + // next send blocks or fails, so ask the host to retry them broadly. + if datagram_send_state_changed { + crate::wakeup::push_datagram_writable(); } Ok(()) } @@ -6640,21 +8321,45 @@ pub fn sys_send( let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; if sock.sock_type == SocketType::Dgram { - if sock.domain == SocketDomain::Inet { - if sock.state != SocketState::Connected { - return Err(Errno::EDESTADDRREQ); + match sock.domain { + SocketDomain::Inet => { + if sock.state != SocketState::Connected { + return Err(Errno::EDESTADDRREQ); + } + let dst_addr = sock.peer_addr; + let dst_port = sock.peer_port; + let result = udp_send_datagram(proc, host, sock_idx, buf, dst_addr, dst_port); + return finish_datagram_send(proc, flags, result); + } + SocketDomain::Inet6 => { + if sock.state != SocketState::Connected { + return Err(Errno::EDESTADDRREQ); + } + let dst_addr = sock.peer_addr6; + let dst_port = sock.peer_port; + let result = udp6_send_datagram(proc, sock_idx, buf, dst_addr, dst_port); + return finish_datagram_send(proc, flags, result); + } + SocketDomain::Unix if sock.state == SocketState::Connected => { + let peer_idx = sock.peer_idx; + if let Some(peer_idx) = peer_idx { + let result = unix_dgram_send_to_sock(proc, sock_idx, peer_idx, buf); + return finish_datagram_send(proc, flags, result); + } + return Err(Errno::ECONNREFUSED); } - let dst_addr = sock.peer_addr; - let dst_port = sock.peer_port; - return udp_send_datagram(proc, host, sock_idx, buf, dst_addr, dst_port); - } - if sock.domain == SocketDomain::Unix && sock.state == SocketState::Connected { - return Ok(buf.len()); + SocketDomain::Unix => {} } } if sock.state != SocketState::Connected { return Err(Errno::ENOTCONN); } + if sock.shut_wr { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + return Err(Errno::EPIPE); + } // MSG_OOB: store the last byte as out-of-band data on the peer socket. if flags & MSG_OOB != 0 { @@ -6662,14 +8367,23 @@ pub fn sys_send( return Err(Errno::EINVAL); } let oob_byte = buf[buf.len() - 1]; - // Find peer socket and set OOB byte. - // For loopback: peer_idx points to socket in same process. - // For cross-process: peer is in another process (handled by global pipe path). - if let Some(peer_idx) = sock.peer_idx { - if let Some(peer_sock) = proc.sockets.get_mut(peer_idx) { - peer_sock.oob_byte = Some(oob_byte); + // For loopback, peer_idx points to a socket in the same process. + // Cross-process streams use the global pipe path and do not have a + // process-local OOB peer. A closed or absent peer is a truthful EPIPE, + // never a successful write or an opportunity to target a reused slot. + let Some(peer_idx) = sock.peer_idx else { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); } - } + return Err(Errno::EPIPE); + }; + let Some(peer_sock) = proc.sockets.get_mut(peer_idx) else { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + return Err(Errno::EPIPE); + }; + peer_sock.oob_byte = Some(oob_byte); return Ok(buf.len()); } @@ -6677,16 +8391,30 @@ pub fn sys_send( SocketDomain::Inet | SocketDomain::Inet6 => { // Loopback path: use pipe buffers if available if sock.send_buf_idx.is_some() { - return sys_write(proc, host, fd, buf); + let nosignal = flags & MSG_NOSIGNAL != 0; + let sigpipe_was_pending = + proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE); + let result = sys_write(proc, host, fd, buf); + // MSG_NOSIGNAL: suppress SIGPIPE raised by write. + if nosignal && !sigpipe_was_pending { + proc.signals.clear(wasm_posix_shared::signal::SIGPIPE); + } + return result; } let net_handle = sock.host_net_handle.ok_or(Errno::ENOTCONN)?; - host.host_net_send(net_handle, buf, flags) + match host.host_net_send(net_handle, buf, flags) { + Err(Errno::EPIPE) => { + if flags & MSG_NOSIGNAL == 0 { + proc.signals.raise(wasm_posix_shared::signal::SIGPIPE); + } + Err(Errno::EPIPE) + } + other => other, + } } SocketDomain::Unix => { - // DGRAM bit-bucket (syslog pattern): data is discarded - if sock.sock_type == crate::socket::SocketType::Dgram { - return Ok(buf.len()); - } + // Connected datagrams returned through the family-specific path + // above. The remaining AF_UNIX path is pipe-backed SOCK_STREAM. let nosignal = flags & MSG_NOSIGNAL != 0; let sigpipe_was_pending = proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE); let result = sys_write(proc, host, fd, buf); @@ -6721,7 +8449,12 @@ pub fn sys_recv( } let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - if sock.sock_type == SocketType::Dgram && sock.domain == SocketDomain::Inet { + if sock.sock_type == SocketType::Dgram + && matches!( + sock.domain, + SocketDomain::Inet | SocketDomain::Inet6 | SocketDomain::Unix + ) + { let (n, _) = sys_recvfrom(proc, host, fd, buf, flags, &mut [])?; return Ok(n); } @@ -6825,18 +8558,45 @@ pub fn sys_getsockopt(proc: &mut Process, fd: i32, level: u32, optname: u32) -> 0 }), SO_RCVBUF | SO_SNDBUF => Ok(DEFAULT_PIPE_CAPACITY as u32), - SO_REUSEADDR | SO_KEEPALIVE | SO_LINGER | SO_BROADCAST => { + SO_REUSEADDR | SO_REUSEPORT | SO_KEEPALIVE | SO_BROADCAST | SO_PASSCRED + | SO_ATTACH_REUSEPORT_CBPF | SO_ZEROCOPY => { Ok(sock.get_option(level, optname).unwrap_or(0)) } + // SO_LINGER and SO_BINDTODEVICE are structured/string-valued and + // handled by dedicated wasm ABI wrappers. + SO_LINGER | SO_BINDTODEVICE => Err(Errno::ENOPROTOOPT), // SO_RCVTIMEO/SO_SNDTIMEO handled by sys_getsockopt_timeout _ => Err(Errno::ENOPROTOOPT), }, + IPPROTO_IP => match optname { + IP_TOS | IP_PKTINFO | IP_MTU_DISCOVER | IP_MULTICAST_IF | IP_MULTICAST_TTL + | IP_MULTICAST_LOOP | IP_MULTICAST_ALL + | MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP + | MCAST_BLOCK_SOURCE | MCAST_UNBLOCK_SOURCE | MCAST_JOIN_SOURCE_GROUP + | MCAST_LEAVE_SOURCE_GROUP => { + Ok(sock.get_option(level, optname).unwrap_or_else(|| match optname { + IP_MULTICAST_TTL | IP_MULTICAST_LOOP => 1, + _ => 0, + })) + } + IP_MTU => Ok(1500), + _ => Err(Errno::ENOPROTOOPT), + }, + IPPROTO_IPV6 => match optname { + IPV6_V6ONLY => Ok(u32::from(ipv6_v6only(sock))), + IPV6_MULTICAST_IF | IPV6_MULTICAST_HOPS | IPV6_MULTICAST_LOOP + | IPV6_RECVPKTINFO | IPV6_RECVTCLASS | IPV6_DONTFRAG | IPV6_TCLASS => { + Ok(sock.get_option(level, optname).unwrap_or(0)) + } + _ => Err(Errno::ENOPROTOOPT), + }, IPPROTO_TCP => match optname { TCP_NODELAY | TCP_CORK | TCP_KEEPIDLE | TCP_KEEPINTVL | TCP_KEEPCNT | TCP_DEFER_ACCEPT | TCP_QUICKACK | TCP_USER_TIMEOUT => { Ok(sock.get_option(level, optname).unwrap_or(0)) } - // TCP_INFO handled separately by sys_getsockopt_tcp_info + // TCP_INFO and TCP_CONGESTION handled separately. + TCP_CONGESTION => Err(Errno::ENOPROTOOPT), _ => Err(Errno::ENOPROTOOPT), }, _ => Err(Errno::ENOPROTOOPT), @@ -6904,6 +8664,23 @@ pub fn sys_getsockopt_tcp_info(proc: &Process, fd: i32) -> Result<[u8; TCP_INFO_ Ok(buf) } +/// Map musl's long64 and time64 socket-timeout numbers to the kernel's +/// architecture-neutral time64 constants. +pub(crate) fn canonical_socket_timeout_optname(level: u32, optname: u32) -> Option { + use wasm_posix_shared::socket::{ + SOL_SOCKET, SO_RCVTIMEO, SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, + }; + + if level != SOL_SOCKET { + return None; + } + match optname { + SO_RCVTIMEO | SO_RCVTIMEO_OLD => Some(SO_RCVTIMEO), + SO_SNDTIMEO | SO_SNDTIMEO_OLD => Some(SO_SNDTIMEO), + _ => None, + } +} + /// Get socket timeout value in microseconds (SO_RCVTIMEO / SO_SNDTIMEO). pub fn sys_getsockopt_timeout(proc: &Process, fd: i32, optname: u32) -> Result { use wasm_posix_shared::socket::*; @@ -6922,8 +8699,166 @@ pub fn sys_getsockopt_timeout(proc: &Process, fd: i32, optname: u32) -> Result Result<(i32, i32), Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + Ok((sock.linger_onoff, sock.linger_seconds)) +} + +/// Set SO_LINGER's structured state. +pub fn sys_setsockopt_linger( + proc: &mut Process, + fd: i32, + l_onoff: i32, + l_linger: i32, +) -> Result<(), Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + // Enabled linger needs either blocking queued-send drainage or an explicit + // reset close mode carried through every kernel/host transport. Kandelo has + // neither contract yet, so reject it instead of storing a no-op promise. + if l_onoff != 0 { + return Err(Errno::EOPNOTSUPP); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + sock.linger_onoff = l_onoff; + sock.linger_seconds = l_linger; + sock.set_option( + wasm_posix_shared::socket::SOL_SOCKET, + wasm_posix_shared::socket::SO_LINGER, + if l_onoff != 0 { 1 } else { 0 }, + ); + Ok(()) +} + +/// Set SO_BINDTODEVICE to a named virtual interface. +pub fn sys_setsockopt_bindtodevice( + proc: &mut Process, + fd: i32, + device: &[u8], +) -> Result<(), Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + if !matches!( + sock.domain, + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6 + ) { + return Err(Errno::ENOPROTOOPT); + } + let name = device.split(|&b| b == 0).next().unwrap_or(device); + if name.is_empty() { + sock.bind_device = None; + sock.set_option( + wasm_posix_shared::socket::SOL_SOCKET, + wasm_posix_shared::socket::SO_BINDTODEVICE, + 0, + ); + return Ok(()); + } + if name != b"lo" && name != b"eth0" { + return Err(Errno::ENODEV); + } + sock.bind_device = Some(name.to_vec()); + sock.set_option( + wasm_posix_shared::socket::SOL_SOCKET, + wasm_posix_shared::socket::SO_BINDTODEVICE, + 1, + ); + Ok(()) +} + +/// Get SO_BINDTODEVICE's bound interface name. +pub fn sys_getsockopt_bindtodevice(proc: &Process, fd: i32) -> Result, Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + if !matches!( + sock.domain, + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6 + ) { + return Err(Errno::ENOPROTOOPT); + } + Ok(sock.bind_device.clone().unwrap_or_default()) +} + +/// Get TCP_CONGESTION's algorithm name. +pub fn sys_getsockopt_tcp_congestion(proc: &Process, fd: i32) -> Result, Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + if !matches!( + sock.domain, + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6 + ) || sock.sock_type != crate::socket::SocketType::Stream + { + return Err(Errno::ENOPROTOOPT); + } + Ok(sock.tcp_congestion.clone()) +} + +/// Set TCP_CONGESTION's algorithm name. +pub fn sys_setsockopt_tcp_congestion( + proc: &mut Process, + fd: i32, + name: &[u8], +) -> Result<(), Errno> { + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let name = name.split(|&b| b == 0).next().unwrap_or(name); + if name.is_empty() { + return Err(Errno::ENOENT); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + if !matches!( + sock.domain, + crate::socket::SocketDomain::Inet | crate::socket::SocketDomain::Inet6 + ) || sock.sock_type != crate::socket::SocketType::Stream + { + return Err(Errno::ENOPROTOOPT); + } + // Kandelo exposes one virtual TCP policy. Accept re-selecting that policy, + // but reject names whose behavior the kernel does not implement. + if name != b"cubic" { + return Err(Errno::ENOENT); + } + sock.tcp_congestion = name.to_vec(); + sock.set_option( + wasm_posix_shared::socket::IPPROTO_TCP, + wasm_posix_shared::socket::TCP_CONGESTION, + 1, + ); + Ok(()) +} + +/// Set socket option value. +pub fn sys_setsockopt( proc: &mut Process, fd: i32, level: u32, @@ -6943,11 +8878,65 @@ pub fn sys_setsockopt( match level { SOL_SOCKET => match optname { - SO_REUSEADDR | SO_KEEPALIVE | SO_RCVBUF | SO_SNDBUF | SO_LINGER | SO_BROADCAST => { + SO_REUSEADDR | SO_REUSEPORT | SO_KEEPALIVE | SO_RCVBUF | SO_SNDBUF + | SO_BROADCAST | SO_PASSCRED | SO_ATTACH_REUSEPORT_CBPF | SO_ZEROCOPY => { + sock.set_option(level, optname, value); + Ok(()) + } + SO_LINGER | SO_BINDTODEVICE => Err(Errno::ENOPROTOOPT), + // SO_RCVTIMEO/SO_SNDTIMEO handled by sys_setsockopt_timeout + _ => Err(Errno::ENOPROTOOPT), + }, + IPPROTO_IP => match optname { + IP_MULTICAST_IF => { + let addr = value.to_le_bytes(); + if addr != [0; 4] && !is_loopback_addr(addr) && !is_virtual_network_addr(addr) { + return Err(Errno::EADDRNOTAVAIL); + } + sock.set_option(level, optname, value); + Ok(()) + } + IP_MULTICAST_TTL => { + if value > u8::MAX as u32 { + return Err(Errno::EINVAL); + } + sock.set_option(level, optname, value); + Ok(()) + } + IP_MULTICAST_LOOP => { + sock.set_option(level, optname, u32::from(value != 0)); + Ok(()) + } + IP_TOS | IP_PKTINFO | IP_MTU_DISCOVER | IP_MULTICAST_ALL + | MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP + | MCAST_BLOCK_SOURCE | MCAST_UNBLOCK_SOURCE | MCAST_JOIN_SOURCE_GROUP + | MCAST_LEAVE_SOURCE_GROUP => { + sock.set_option(level, optname, value); + Ok(()) + } + IP_MTU => Err(Errno::ENOPROTOOPT), + _ => Err(Errno::ENOPROTOOPT), + }, + IPPROTO_IPV6 => match optname { + IPV6_V6ONLY => { + if sock.domain != crate::socket::SocketDomain::Inet6 { + return Err(Errno::ENOPROTOOPT); + } + if sock.bind_port != 0 { + return Err(Errno::EINVAL); + } + if sock.sock_type == crate::socket::SocketType::Dgram && value == 0 { + return Err(Errno::EOPNOTSUPP); + } + sock.set_option(level, optname, if value != 0 { 1 } else { 0 }); + Ok(()) + } + IPV6_MULTICAST_IF | IPV6_MULTICAST_HOPS | IPV6_MULTICAST_LOOP + | IPV6_PKTINFO | IPV6_RECVPKTINFO | IPV6_RECVTCLASS | IPV6_DONTFRAG + | IPV6_TCLASS => { sock.set_option(level, optname, value); Ok(()) } - // SO_RCVTIMEO/SO_SNDTIMEO handled by sys_setsockopt_timeout _ => Err(Errno::ENOPROTOOPT), }, IPPROTO_TCP => match optname { @@ -6956,6 +8945,7 @@ pub fn sys_setsockopt( sock.set_option(level, optname, value); Ok(()) } + TCP_CONGESTION => Err(Errno::ENOPROTOOPT), _ => Err(Errno::ENOPROTOOPT), }, _ => Err(Errno::ENOPROTOOPT), @@ -7028,6 +9018,9 @@ pub fn sys_bind( if sock.sock_type == SocketType::Dgram { return udp_bind_socket(proc, host, sock_idx, ip, port); } + if !bind_device_allows_ipv4(sock, ip, true) { + return Err(Errno::EADDRNOTAVAIL); + } let assigned_port = if port == 0 { let p = proc.next_ephemeral_port; @@ -7046,6 +9039,66 @@ pub fn sys_bind( sock.state = SocketState::Bound; Ok(()) } + SocketDomain::Inet6 => { + let (ip, port) = parse_sockaddr_in6(addr)?; + if sock.sock_type == SocketType::Dgram { + return udp6_bind_socket(proc, sock_idx, ip, port); + } + if !(is_loopback_addr6(ip) || is_unspecified_addr6(ip)) { + return Err(Errno::EADDRNOTAVAIL); + } + if !bind_device_allows_ipv6(sock, ip) { + return Err(Errno::EADDRNOTAVAIL); + } + + let dual_stack = is_unspecified_addr6(ip) && !ipv6_v6only(sock); + let assigned_port = if port == 0 { + if proc.next_ephemeral_port < 49152 { + proc.next_ephemeral_port = 49152; + } + let start = proc.next_ephemeral_port; + loop { + let candidate = proc.next_ephemeral_port; + bump_ephemeral_port(proc); + if crate::socket::tcp6_can_bind(proc.pid, sock_idx, ip, candidate) + && (!dual_stack + || crate::socket::tcp_can_bind( + proc.pid, + sock_idx, + [0, 0, 0, 0], + candidate, + )) + { + break candidate; + } + if proc.next_ephemeral_port == start { + return Err(Errno::EADDRINUSE); + } + } + } else { + port + }; + + // Linux defaults AF_INET6 sockets to dual-stack unless + // IPV6_V6ONLY is enabled. A wildcard IPv6 stream bind therefore + // also occupies the IPv4 port space and must conflict with an + // AF_INET bind to the same port. Specific ::1 binds stay IPv6-only + // and can coexist with 127.0.0.1. + crate::socket::tcp6_register(proc.pid, sock_idx, ip, assigned_port)?; + if dual_stack { + if let Err(err) = + crate::socket::tcp_register(proc.pid, sock_idx, [0, 0, 0, 0], assigned_port) + { + crate::socket::tcp6_unregister(proc.pid, sock_idx); + return Err(err); + } + } + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + sock.bind_addr6 = ip; + sock.bind_port = assigned_port; + sock.state = SocketState::Bound; + Ok(()) + } SocketDomain::Unix => { // sockaddr_un: family(2) + sun_path (null-terminated, up to 108 bytes) if addr.len() < 3 { @@ -7053,15 +9106,34 @@ pub fn sys_bind( } // Extract path: starts at offset 2, null-terminated let path_bytes = &addr[2..]; - let path_end = path_bytes - .iter() - .position(|&b| b == 0) - .unwrap_or(path_bytes.len()); - if path_end == 0 { - return Err(Errno::EINVAL); - } - let sun_path = &path_bytes[..path_end]; - let resolved = crate::path::resolve_path(sun_path, &proc.cwd); + let (resolved, abstract_unix) = if path_bytes.first().copied() == Some(0) { + if path_bytes.len() < 2 { + return Err(Errno::EINVAL); + } + // Linux abstract namespace sockets are identified by the raw + // bytes after sun_family, including the leading NUL. No + // filesystem inode is created and embedded/trailing NUL bytes + // are part of the address. + (path_bytes.to_vec(), true) + } else { + let path_end = path_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(path_bytes.len()); + if path_end == 0 { + return Err(Errno::EINVAL); + } + ( + resolve_namespace_path( + proc, + host, + &path_bytes[..path_end], + PathResolveOptions::CREATE_ENTRY, + )? + .path, + false, + ) + }; // POSIX: bind() must create a filesystem inode at sun_path so // chmod/stat/ls find a node there. Do that first via host O_CREAT| @@ -7071,22 +9143,34 @@ pub fn sys_bind( // here after the package-management rebase dropped it; the same // code lives at the merge base but didn't survive into the // rebased branch.) - use wasm_posix_shared::flags::{O_CREAT, O_EXCL, O_WRONLY}; - check_open_permissions(proc, host, &resolved, O_CREAT | O_EXCL | O_WRONLY)?; - let h = match host.host_open(&resolved, O_CREAT | O_EXCL | O_WRONLY, 0o600) { - Ok(h) => h, - Err(Errno::EEXIST) => return Err(Errno::EADDRINUSE), - Err(e) => return Err(e), - }; - host.host_chown(&resolved, proc.euid, proc.egid)?; - let _ = host.host_close(h); + if !abstract_unix { + use wasm_posix_shared::flags::{O_CREAT, O_EXCL, O_WRONLY}; + check_open_permissions(proc, host, &resolved, O_CREAT | O_EXCL | O_WRONLY)?; + // Linux pathname sockets start with every permission bit enabled, + // filtered through the creating process's umask. Abstract sockets + // have no backing VFS inode at all. + let socket_mode = 0o777 & !proc.umask; + let h = match host.host_open( + &resolved, + O_CREAT | O_EXCL | O_WRONLY, + socket_mode, + ) { + Ok(h) => h, + Err(Errno::EEXIST) => return Err(Errno::EADDRINUSE), + Err(e) => return Err(e), + }; + host.host_chown(&resolved, proc.euid, proc.egid)?; + let _ = host.host_close(h); + } // Register in global Unix socket registry. If a stale entry exists // (host had no inode but registry did — shouldn't happen normally) // unwind the host inode so we don't leak. let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; if !registry.register(resolved.clone(), proc.pid, sock_idx) { - let _ = host.host_unlink(&resolved); + if !abstract_unix { + let _ = host.host_unlink(&resolved); + } return Err(Errno::EADDRINUSE); } @@ -7095,7 +9179,6 @@ pub fn sys_bind( sock.state = SocketState::Bound; Ok(()) } - SocketDomain::Inet6 => Err(Errno::EADDRNOTAVAIL), } } @@ -7116,36 +9199,117 @@ pub fn sys_listen( return Err(Errno::ENOTSOCK); } let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - if sock.sock_type != SocketType::Stream { + let (domain, sock_type, state) = { + let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + (sock.domain, sock.sock_type, sock.state) + }; + if sock_type != SocketType::Stream { return Err(Errno::EOPNOTSUPP); } - if sock.state != SocketState::Bound && sock.state != SocketState::Listening { + if state == SocketState::Unbound { + // Linux auto-binds unbound INET stream sockets on listen(2). This is + // observable through getsockname() and lets standard socket option + // probes listen without an explicit bind. + match domain { + SocketDomain::Inet => { + let mut wildcard = [0u8; 16]; + wildcard[0] = wasm_posix_shared::socket::AF_INET as u8; + sys_bind(proc, host, fd, &wildcard)?; + } + SocketDomain::Inet6 => { + let mut wildcard = [0u8; 28]; + wildcard[0] = wasm_posix_shared::socket::AF_INET6 as u8; + sys_bind(proc, host, fd, &wildcard)?; + } + SocketDomain::Unix => return Err(Errno::EINVAL), + } + } else if state != SocketState::Bound && state != SocketState::Listening { return Err(Errno::EINVAL); } + + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + if let Some(value) = sock.get_option( + wasm_posix_shared::socket::IPPROTO_TCP, + wasm_posix_shared::socket::TCP_DEFER_ACCEPT, + ) { + if value > 0 { + // Linux rounds TCP_DEFER_ACCEPT to an internal retransmission + // timeout. Preserve the observable contract that getsockopt() + // after listen() returns a value greater than the requested one. + sock.set_option( + wasm_posix_shared::socket::IPPROTO_TCP, + wasm_posix_shared::socket::TCP_DEFER_ACCEPT, + value.saturating_add(1), + ); + } + } sock.state = SocketState::Listening; if sock.accept_wake_idx.is_none() { sock.accept_wake_idx = Some(crate::wakeup::alloc_accept_wake_idx()); } - // For AF_INET listeners, allocate a shared accept queue that fork - // children will inherit. This way every process sharing this listener - // pulls from the same queue (POSIX semantics) — see socket.rs. + // Every stream listener uses a shared accept queue that fork children + // inherit. This lets pre-fork AF_INET, AF_INET6, and AF_UNIX servers race + // on one kernel-owned queue rather than receiving per-process copies. let domain = sock.domain; - if domain == SocketDomain::Inet && sock.shared_backlog_idx.is_none() { + if sock.shared_backlog_idx.is_none() { let backlog_idx = unsafe { crate::socket::shared_listener_backlog_table().alloc() }; sock.shared_backlog_idx = Some(backlog_idx); } - // Notify the host for AF_INET sockets so it can open a real TCP server + // Notify the host so it can open a real TCP server. The bridge transport is + // IPv4 today; AF_INET6 loopback listeners are registered on the IPv4 + // loopback transport while the guest-facing socket remains AF_INET6. This + // gives cross-process ::1 loopback the same accept/backlog semantics as + // 127.0.0.1 without exposing host-network details to the guest. let port = sock.bind_port; let addr = sock.bind_addr; - if domain == SocketDomain::Inet { - let _ = host.host_net_listen(fd, port, &addr); + match domain { + SocketDomain::Inet => { + let _ = host.host_net_listen(fd, port, &addr); + } + SocketDomain::Inet6 => { + // The host transport is IPv4. Register only a genuine dual-stack + // wildcard listener there; native ::1 and V6ONLY listeners stay + // on the kernel's IPv6 loopback path and cannot admit IPv4 peers. + if is_unspecified_addr6(sock.bind_addr6) && !ipv6_v6only(sock) { + let _ = host.host_net_listen(fd, port, &[127, 0, 0, 1]); + } + } + SocketDomain::Unix => {} } Ok(()) } +fn discard_accepted_socket_without_fd(proc: &mut Process, sock_idx: usize) { + let Some(sock) = proc.sockets.get(sock_idx) else { + return; + }; + let (recv_idx, send_idx, peer_idx) = + (sock.recv_buf_idx, sock.send_buf_idx, sock.peer_idx); + if let Some(peer_idx) = peer_idx { + if let Some(peer) = proc.sockets.get_mut(peer_idx) { + if peer.peer_idx == Some(sock_idx) { + peer.peer_idx = None; + } + } + } + let pipes = unsafe { crate::pipe::global_pipe_table() }; + if let Some(recv_idx) = recv_idx { + if let Some(pipe) = pipes.get_mut(recv_idx) { + pipe.close_read_end(); + } + pipes.free_if_closed(recv_idx); + } + if let Some(send_idx) = send_idx { + if let Some(pipe) = pipes.get_mut(send_idx) { + pipe.close_write_end(); + } + pipes.free_if_closed(send_idx); + } + proc.sockets.free(sock_idx); +} + /// Accept a connection on a listening socket. /// /// Pops a pending connection from the listener's backlog, creates an OFD + FD @@ -7158,6 +9322,9 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result if ofd.file_type != FileType::Socket { return Err(Errno::ENOTSOCK); } + // Linux accept() does not inherit O_NONBLOCK from the listener. accept4() + // applies SOCK_NONBLOCK explicitly in the wasm wrapper after this returns. + let accepted_status_flags = O_RDWR; let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; @@ -7168,37 +9335,74 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result return Err(Errno::EINVAL); } - // AF_INET listeners use a shared cross-process accept queue. Try - // popping from there first; the accepted SocketInfo is created - // lazily here in the accepting process. See socket.rs. + // All listeners use a shared cross-process accept queue. The accepted + // SocketInfo is created lazily in whichever process wins accept(). if let Some(shared_idx) = sock.shared_backlog_idx { + let domain = sock.domain; let bind_addr = sock.bind_addr; + let bind_addr6 = sock.bind_addr6; + let bind_path = sock.bind_path.clone(); let bind_port = sock.bind_port; let pending = unsafe { crate::socket::shared_listener_backlog_table().pop(shared_idx) }; if let Some(pc) = pending { - let mut accepted = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); + let mut accepted = SocketInfo::new(domain, SocketType::Stream, 0); accepted.state = SocketState::Connected; accepted.recv_buf_idx = Some(pc.recv_pipe_idx); accepted.send_buf_idx = Some(pc.send_pipe_idx); - accepted.bind_addr = bind_addr; accepted.bind_port = bind_port; - accepted.peer_addr = pc.peer_addr; + match domain { + SocketDomain::Inet => { + accepted.bind_addr = bind_addr; + accepted.peer_addr = pc.peer_addr; + } + SocketDomain::Inet6 => { + accepted.bind_addr6 = bind_addr6; + accepted.peer_addr6 = if pc.peer_is_ipv6 { + pc.peer_addr6 + } else { + ipv4_mapped_addr6(pc.peer_addr) + }; + } + SocketDomain::Unix => { + accepted.bind_path = bind_path; + } + } accepted.peer_port = pc.peer_port; accepted.global_pipes = true; let accepted_sock_idx = proc.sockets.alloc(accepted); + if domain == SocketDomain::Unix && pc.peer_pid == proc.pid { + if let Some(peer_idx) = pc.peer_sock_idx { + let peer_matches = proc.sockets.get(peer_idx).is_some_and(|peer| { + peer.domain == SocketDomain::Unix + && peer.sock_type == SocketType::Stream + && peer.state == SocketState::Connected + && peer.send_buf_idx == Some(pc.recv_pipe_idx) + && peer.recv_buf_idx == Some(pc.send_pipe_idx) + }); + if peer_matches { + proc.sockets.get_mut(accepted_sock_idx).unwrap().peer_idx = Some(peer_idx); + proc.sockets.get_mut(peer_idx).unwrap().peer_idx = Some(accepted_sock_idx); + } + } + } let host_handle = -((accepted_sock_idx as i64) + 1); let ofd_idx = proc.ofd_table.create( FileType::Socket, - O_RDWR, + accepted_status_flags, host_handle, b"/dev/socket".to_vec(), ); - let new_fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0)?; - return Ok(new_fd); + return match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0) { + Ok(new_fd) => Ok(new_fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + discard_accepted_socket_without_fd(proc, accepted_sock_idx); + Err(err) + } + }; } - // Shared queue empty — fall through to per-process backlog - // for AF_UNIX-style entries (none for INET listeners). We return - // EAGAIN below if both queues are empty. + // Shared queue empty — retain the inline fallback for legacy/manual + // listener state, then return EAGAIN if both queues are empty. let _ = SocketType::Stream; // silence unused-import warning if path unused let _ = SocketDomain::Inet; } @@ -7213,14 +9417,20 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result let host_handle = -((accepted_sock_idx as i64) + 1); let ofd_idx = proc.ofd_table.create( FileType::Socket, - O_RDWR, + accepted_status_flags, host_handle, b"/dev/socket".to_vec(), ); // Allocate fd - let new_fd = proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0)?; - Ok(new_fd) + match proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0) { + Ok(new_fd) => Ok(new_fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + discard_accepted_socket_without_fd(proc, accepted_sock_idx); + Err(err) + } + } } /// Connect a socket to an address. @@ -7228,10 +9438,12 @@ pub fn sys_accept(proc: &mut Process, _host: &mut dyn HostIO, fd: i32) -> Result /// For AF_INET sockets connecting to 127.0.0.1, performs loopback connect: /// finds the listening socket on the target port, creates pipe pairs, and /// pushes a pending connection to the listener's backlog. -/// For non-loopback AF_INET/AF_INET6, delegates to the host via `host_net_connect`. +/// Non-loopback AF_INET streams delegate to the host via `host_net_connect`; +/// AF_INET6 currently exposes only local `::`/`::1` stream routing. /// For AF_UNIX SOCK_STREAM, performs same-process connect via the global /// UnixSocketRegistry (cross-process connect is handled in wasm_api.rs). -/// For AF_UNIX SOCK_DGRAM, connect succeeds as a bit-bucket. +/// For AF_UNIX SOCK_DGRAM, connects validate a live same-process datagram +/// endpoint; unsupported cross-process routing fails without misdelivery. pub fn sys_connect( proc: &mut Process, host: &mut dyn HostIO, @@ -7266,8 +9478,9 @@ pub fn sys_connect( return Ok(()); } - let (ip, port) = parse_sockaddr_in(addr)?; - if ip == [0, 0, 0, 0] && port == 0 { + let (raw_ip, port) = parse_sockaddr_in(addr)?; + let ip = udp_canonical_dst_addr(raw_ip); + if raw_ip == [0, 0, 0, 0] && port == 0 { return Err(Errno::EADDRNOTAVAIL); } if ip == [255, 255, 255, 255] { @@ -7282,6 +9495,9 @@ pub fn sys_connect( if !is_supported_udp_route_addr(ip) { return Err(Errno::ENETUNREACH); } + if !bind_device_allows_ipv4(sock, ip, false) { + return Err(Errno::ENETUNREACH); + } udp_ensure_bound(proc, host, sock_idx, udp_route_local_addr(ip))?; { @@ -7298,40 +9514,168 @@ pub fn sys_connect( if sock.state == SocketState::Connected { return Err(Errno::EISCONN); } - // Parse sockaddr_in: family(2) + port(2 big-endian) + addr(4) - if addr.len() < 8 { - return Err(Errno::EINVAL); - } - let port = u16::from_be_bytes([addr[2], addr[3]]); - let ip = [addr[4], addr[5], addr[6], addr[7]]; - - // Check for loopback address (127.0.0.1) - let is_loopback = ip == [127, 0, 0, 1]; - if is_loopback && sock.domain == SocketDomain::Inet { - // UDP DGRAM connect: just record peer address (no listener needed) + if sock.domain == SocketDomain::Inet6 { if sock.sock_type == SocketType::Dgram { - let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - let mut client_port = client_sock.bind_port; - if client_port == 0 { - client_port = proc.next_ephemeral_port; - proc.next_ephemeral_port = proc.next_ephemeral_port.wrapping_add(1); - if proc.next_ephemeral_port == 0 { - proc.next_ephemeral_port = 49152; - } + let (raw_ip6, port) = parse_sockaddr_in6(addr)?; + if is_unspecified_addr6(raw_ip6) && port == 0 { + return Err(Errno::EADDRNOTAVAIL); } - let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - client.state = SocketState::Connected; - client.peer_addr = ip; - client.peer_port = port; - if client.bind_port == 0 { - client.bind_port = client_port; - client.bind_addr = [127, 0, 0, 1]; + let ip6 = if is_unspecified_addr6(raw_ip6) { + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + } else { + raw_ip6 + }; + if !is_loopback_addr6(ip6) { + return Err(Errno::ENETUNREACH); } + if !bind_device_allows_ipv6(sock, ip6) { + return Err(Errno::ENETUNREACH); + } + udp6_ensure_bound( + proc, + sock_idx, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1], + )?; + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + sock.peer_addr6 = ip6; + sock.peer_port = port; + sock.state = SocketState::Connected; return Ok(()); } + if sock.sock_type != SocketType::Stream { + return Err(Errno::EOPNOTSUPP); + } + let (raw_ip6, port) = parse_sockaddr_in6(addr)?; + if !(is_loopback_addr6(raw_ip6) || is_unspecified_addr6(raw_ip6)) { + return Err(Errno::EADDRNOTAVAIL); + } + let ip6 = if is_unspecified_addr6(raw_ip6) { + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + } else { + raw_ip6 + }; + if !bind_device_allows_ipv6(sock, ip6) { + return Err(Errno::ENETUNREACH); + } - // TCP STREAM connect: find listening socket on target port + // AF_INET6 support is currently local-loopback. That is enough + // to model standard bind/listen/getsockname behavior and makes + // unsupported remote IPv6 fail deterministically instead of + // being mis-parsed as IPv4. + let mut listener_idx = None; + let sock_count = proc.sockets.len(); + for i in 0..sock_count { + if let Some(s) = proc.sockets.get(i) { + if s.domain == SocketDomain::Inet6 + && s.state == SocketState::Listening + && s.bind_port == port + && s.sock_type == SocketType::Stream + && (is_unspecified_addr6(s.bind_addr6) || s.bind_addr6 == ip6) + { + listener_idx = Some(i); + break; + } + } + } + let listener_idx = listener_idx.ok_or(Errno::ECONNREFUSED)?; + + let (pipe_a_idx, pipe_b_idx) = unsafe { + crate::pipe::global_pipe_table() + .alloc_pair(PipeBuffer::new(65536), PipeBuffer::new(65536)) + }; + + let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + let client_addr6 = if is_unspecified_addr6(client_sock.bind_addr6) { + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1] + } else { + client_sock.bind_addr6 + }; + let mut client_port = client_sock.bind_port; + if client_port == 0 { + client_port = proc.next_ephemeral_port; + proc.next_ephemeral_port = proc.next_ephemeral_port.wrapping_add(1); + if proc.next_ephemeral_port == 0 { + proc.next_ephemeral_port = 49152; + } + } + + let listener = proc.sockets.get(listener_idx).ok_or(Errno::EBADF)?; + let mut accepted_sock = SocketInfo::new(SocketDomain::Inet6, SocketType::Stream, 0); + accepted_sock.state = SocketState::Connected; + accepted_sock.recv_buf_idx = Some(pipe_a_idx); + accepted_sock.send_buf_idx = Some(pipe_b_idx); + accepted_sock.global_pipes = true; + accepted_sock.bind_addr6 = listener.bind_addr6; + accepted_sock.bind_port = listener.bind_port; + accepted_sock.peer_addr6 = client_addr6; + accepted_sock.peer_port = client_port; + let accepted_idx = proc.sockets.alloc(accepted_sock); + + let listener = proc.sockets.get_mut(listener_idx).ok_or(Errno::EBADF)?; + listener.listen_backlog.push(accepted_idx); + let accept_wake_idx = listener.accept_wake_idx; + + let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.send_buf_idx = Some(pipe_a_idx); + client.recv_buf_idx = Some(pipe_b_idx); + client.state = SocketState::Connected; + client.peer_addr6 = ip6; + client.peer_port = port; + client.peer_idx = Some(accepted_idx); + client.global_pipes = true; + if client.bind_port == 0 { + client.bind_port = client_port; + client.bind_addr6 = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + } + + let accepted = proc.sockets.get_mut(accepted_idx).ok_or(Errno::EBADF)?; + accepted.peer_idx = Some(sock_idx); + + if let Some(idx) = accept_wake_idx { + crate::wakeup::push_accept(idx); + } + + return Ok(()); + } + + // Parse sockaddr_in: family(2) + port(2 big-endian) + addr(4) + if addr.len() < 8 { + return Err(Errno::EINVAL); + } + let port = u16::from_be_bytes([addr[2], addr[3]]); + let ip = [addr[4], addr[5], addr[6], addr[7]]; + if !bind_device_allows_ipv4(sock, ip, false) { + return Err(Errno::ENETUNREACH); + } + + // Check for loopback address (127.0.0.1) + let is_loopback = ip == [127, 0, 0, 1]; + + if is_loopback && sock.domain == SocketDomain::Inet { + // UDP DGRAM connect: just record peer address (no listener needed) + if sock.sock_type == SocketType::Dgram { + let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + let mut client_port = client_sock.bind_port; + if client_port == 0 { + client_port = proc.next_ephemeral_port; + proc.next_ephemeral_port = proc.next_ephemeral_port.wrapping_add(1); + if proc.next_ephemeral_port == 0 { + proc.next_ephemeral_port = 49152; + } + } + let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.state = SocketState::Connected; + client.peer_addr = ip; + client.peer_port = port; + if client.bind_port == 0 { + client.bind_port = client_port; + client.bind_addr = [127, 0, 0, 1]; + } + return Ok(()); + } + + // TCP STREAM connect: find listening socket on target port let mut listener_idx = None; let sock_count = proc.sockets.len(); for i in 0..sock_count { @@ -7339,6 +9683,15 @@ pub fn sys_connect( if s.state == SocketState::Listening && s.bind_port == port && s.sock_type == SocketType::Stream + && match s.domain { + SocketDomain::Inet => { + s.bind_addr == [0; 4] || s.bind_addr == ip + } + SocketDomain::Inet6 => { + is_unspecified_addr6(s.bind_addr6) && !ipv6_v6only(s) + } + SocketDomain::Unix => false, + } { listener_idx = Some(i); break; @@ -7370,22 +9723,27 @@ pub fn sys_connect( } } - // Create accepted socket (server side) with cross-connected pipes - let mut accepted_sock = SocketInfo::new(SocketDomain::Inet, SocketType::Stream, 0); + // Create the server side in the listener's domain. An IPv4 + // client accepted by a dual-stack IPv6 wildcard listener is + // reported as an IPv4-mapped IPv6 peer. + let listener = proc.sockets.get(listener_idx).ok_or(Errno::EBADF)?; + let listener_domain = listener.domain; + let listener_addr = listener.bind_addr; + let listener_addr6 = listener.bind_addr6; + let listener_port = listener.bind_port; + let mut accepted_sock = + SocketInfo::new(listener_domain, SocketType::Stream, 0); accepted_sock.state = SocketState::Connected; accepted_sock.recv_buf_idx = Some(pipe_a_idx); // reads from pipe_a (client's writes) accepted_sock.send_buf_idx = Some(pipe_b_idx); // writes to pipe_b (client's reads) - accepted_sock.bind_addr = proc - .sockets - .get(listener_idx) - .map(|s| s.bind_addr) - .unwrap_or([0; 4]); - accepted_sock.bind_port = proc - .sockets - .get(listener_idx) - .map(|s| s.bind_port) - .unwrap_or(0); - accepted_sock.peer_addr = client_addr; + accepted_sock.bind_port = listener_port; + if listener_domain == SocketDomain::Inet6 { + accepted_sock.bind_addr6 = listener_addr6; + accepted_sock.peer_addr6 = ipv4_mapped_addr6(client_addr); + } else { + accepted_sock.bind_addr = listener_addr; + accepted_sock.peer_addr = client_addr; + } accepted_sock.peer_port = client_port; accepted_sock.global_pipes = true; let accepted_idx = proc.sockets.alloc(accepted_sock); @@ -7419,13 +9777,17 @@ pub fn sys_connect( Ok(()) } else { - // External connection: two-phase. First call kicks off the - // async host-side connect; subsequent calls (driven by the - // userspace poll/getsockopt loop) query host_net_connect_status - // until the TCP handshake either completes or errors. EAGAIN - // surfaces while still in flight. + // External AF_INET connection: two-phase. The first call kicks + // off the async host-side connect; subsequent calls (driven by + // a blocking host retry or a userspace poll/getsockopt loop) + // query host_net_connect_status until the TCP handshake either + // completes or errors. Report the socket-facing connect errnos + // while it is pending rather than leaking HostIO's internal + // EAGAIN retry sentinel. AF_UNIX and local/virtual routes do not + // enter this branch. let net_handle = sock_idx as i32; - if sock.state != SocketState::Connecting { + let was_connecting = sock.state == SocketState::Connecting; + if !was_connecting { host.host_net_connect(net_handle, &ip, port)?; let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; sock.state = SocketState::Connecting; @@ -7437,7 +9799,11 @@ pub fn sys_connect( sock.state = SocketState::Connected; Ok(()) } - Err(Errno::EAGAIN) => Err(Errno::EAGAIN), + Err(Errno::EAGAIN) => Err(if was_connecting { + Errno::EALREADY + } else { + Errno::EINPROGRESS + }), Err(e) => { let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; sock.state = SocketState::Closed; @@ -7450,9 +9816,70 @@ pub fn sys_connect( SocketDomain::Unix => { let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; if sock.sock_type == SocketType::Dgram { - // SOCK_DGRAM connect on AF_UNIX succeeds as a bit-bucket (syslog pattern) - let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - sock.state = SocketState::Connected; + if addr.len() < 3 { + return Err(Errno::EINVAL); + } + let path_bytes = &addr[2..]; + let resolved = if path_bytes.first().copied() == Some(0) { + if path_bytes.len() < 2 { + return Err(Errno::EINVAL); + } + path_bytes.to_vec() + } else { + let path_end = path_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(path_bytes.len()); + if path_end == 0 { + return Err(Errno::EINVAL); + } + resolve_namespace_path( + proc, + host, + &path_bytes[..path_end], + PathResolveOptions::FOLLOW, + )? + .path + }; + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + let peer = registry.lookup(&resolved).ok_or(Errno::ECONNREFUSED)?; + // Cross-process datagram routing needs ProcessTable ownership + // and is handled by a later machine-wide routing repair. Fail + // truthfully here instead of indexing this process's socket + // table with another process's slot number. + if peer.pid != proc.pid { + return Err(Errno::ECONNREFUSED); + } + let peer_idx = peer.sock_idx; + let target = proc.sockets.get(peer_idx).ok_or(Errno::ECONNREFUSED)?; + if target.domain != SocketDomain::Unix + || target.sock_type != SocketType::Dgram + || !matches!(target.state, SocketState::Bound | SocketState::Connected) + { + return Err(Errno::ECONNREFUSED); + } + if !unix_dgram_target_accepts_sender(target, sock_idx) { + return Err(Errno::EPERM); + } + + let owner_pid = proc.pid; + let send_state_changed = { + let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + let association_changed = sock.state != SocketState::Connected + || sock.peer_idx != Some(peer_idx); + sock.peer_idx = Some(peer_idx); + sock.state = SocketState::Connected; + let queued_before = sock.dgram_queue.len(); + sock.dgram_queue + .retain(|datagram| unix_dgram_matches_peer(owner_pid, peer_idx, datagram)); + association_changed || sock.dgram_queue.len() != queued_before + }; + // Connecting changes both this socket's destination and which + // senders it accepts. Even if a selected peer's messages keep + // the queue full, rejected writers must wake to observe EPERM. + if send_state_changed { + crate::wakeup::push_datagram_writable(); + } return Ok(()); } if sock.state == SocketState::Connected { @@ -7464,14 +9891,27 @@ pub fn sys_connect( return Err(Errno::EINVAL); } let path_bytes = &addr[2..]; - let path_end = path_bytes - .iter() - .position(|&b| b == 0) - .unwrap_or(path_bytes.len()); - if path_end == 0 { - return Err(Errno::EINVAL); - } - let resolved = crate::path::resolve_path(&path_bytes[..path_end], &proc.cwd); + let resolved = if path_bytes.first().copied() == Some(0) { + if path_bytes.len() < 2 { + return Err(Errno::EINVAL); + } + path_bytes.to_vec() + } else { + let path_end = path_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(path_bytes.len()); + if path_end == 0 { + return Err(Errno::EINVAL); + } + resolve_namespace_path( + proc, + host, + &path_bytes[..path_end], + PathResolveOptions::FOLLOW, + )? + .path + }; // Look up the path in the global Unix socket registry let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; @@ -7491,39 +9931,63 @@ pub fn sys_connect( if listener.state != SocketState::Listening { return Err(Errno::ECONNREFUSED); } + let shared_idx = listener.shared_backlog_idx; + let accept_wake_idx = listener.accept_wake_idx; // Create pipe pair for bidirectional communication (in global table for fork safety) let pipe_table = unsafe { crate::pipe::global_pipe_table() }; let pipe_a_idx = pipe_table.alloc(PipeBuffer::new(65536)); let pipe_b_idx = pipe_table.alloc(PipeBuffer::new(65536)); - // Create accepted socket (server side) - let mut accepted_sock = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); - accepted_sock.state = SocketState::Connected; - accepted_sock.recv_buf_idx = Some(pipe_a_idx); // reads client's writes - accepted_sock.send_buf_idx = Some(pipe_b_idx); // writes to client's reads - accepted_sock.global_pipes = true; - let accepted_idx = proc.sockets.alloc(accepted_sock); - - // Push to listener's backlog - let listener = proc - .sockets - .get_mut(listener_sock_idx) - .ok_or(Errno::EBADF)?; - listener.listen_backlog.push(accepted_idx); - let accept_wake_idx = listener.accept_wake_idx; + if let Some(shared_idx) = shared_idx { + let pending = crate::socket::PendingConnection { + peer_addr: [0; 4], + peer_addr6: [0; 16], + peer_is_ipv6: false, + peer_port: 0, + peer_pid: proc.pid, + peer_sock_idx: Some(sock_idx), + recv_pipe_idx: pipe_a_idx, + send_pipe_idx: pipe_b_idx, + }; + if !unsafe { + crate::socket::shared_listener_backlog_table().push(shared_idx, pending) + } { + pipe_table.discard_unclaimed(pipe_a_idx); + pipe_table.discard_unclaimed(pipe_b_idx); + return Err(Errno::ECONNREFUSED); + } - // Set up client socket - let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - client.send_buf_idx = Some(pipe_a_idx); // writes to pipe_a (server's reads) - client.recv_buf_idx = Some(pipe_b_idx); // reads from pipe_b (server's writes) - client.state = SocketState::Connected; - client.peer_idx = Some(accepted_idx); - client.global_pipes = true; + let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.send_buf_idx = Some(pipe_a_idx); + client.recv_buf_idx = Some(pipe_b_idx); + client.state = SocketState::Connected; + client.peer_idx = None; + client.global_pipes = true; + } else { + // Defensive compatibility for manually restored listener state + // without a shared queue. + let mut accepted_sock = + SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); + accepted_sock.state = SocketState::Connected; + accepted_sock.recv_buf_idx = Some(pipe_a_idx); + accepted_sock.send_buf_idx = Some(pipe_b_idx); + accepted_sock.global_pipes = true; + let accepted_idx = proc.sockets.alloc(accepted_sock); - // Set peer_idx on accepted socket - let accepted = proc.sockets.get_mut(accepted_idx).ok_or(Errno::EBADF)?; - accepted.peer_idx = Some(sock_idx); + proc.sockets + .get_mut(listener_sock_idx) + .ok_or(Errno::EBADF)? + .listen_backlog + .push(accepted_idx); + let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.send_buf_idx = Some(pipe_a_idx); + client.recv_buf_idx = Some(pipe_b_idx); + client.state = SocketState::Connected; + client.peer_idx = Some(accepted_idx); + client.global_pipes = true; + proc.sockets.get_mut(accepted_idx).unwrap().peer_idx = Some(sock_idx); + } if let Some(idx) = accept_wake_idx { crate::wakeup::push_accept(idx); @@ -7556,10 +10020,10 @@ pub fn sys_getaddrinfo( /// bound DGRAM socket and pushes the datagram to its queue. pub fn sys_sendto( proc: &mut Process, - _host: &mut dyn HostIO, + host: &mut dyn HostIO, fd: i32, buf: &[u8], - _flags: u32, + flags: u32, addr: &[u8], ) -> Result { use crate::socket::{SocketDomain, SocketState, SocketType}; @@ -7572,27 +10036,62 @@ pub fn sys_sendto( let sock_idx = (-(ofd.host_handle + 1)) as usize; let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - // AF_UNIX DGRAM connected sockets: bit-bucket (syslog pattern via send→sendto) - if sock.domain == SocketDomain::Unix - && sock.sock_type == SocketType::Dgram - && sock.state == SocketState::Connected - { - return Ok(buf.len()); - } - if addr.is_empty() { if sock.state == SocketState::Connected { - return sys_send(proc, _host, fd, buf, _flags); + return sys_send(proc, host, fd, buf, flags); } return Err(Errno::EDESTADDRREQ); } - if sock.domain != SocketDomain::Inet || sock.sock_type != SocketType::Dgram { + if sock.sock_type != SocketType::Dgram { return Err(Errno::EOPNOTSUPP); } - let (dst_ip, dst_port) = parse_sockaddr_in(addr)?; - udp_send_datagram(proc, _host, sock_idx, buf, dst_ip, dst_port) + let result = match sock.domain { + SocketDomain::Inet => { + let (dst_ip, dst_port) = parse_sockaddr_in(addr)?; + udp_send_datagram(proc, host, sock_idx, buf, dst_ip, dst_port) + } + SocketDomain::Inet6 => { + let (dst_ip, dst_port) = parse_sockaddr_in6(addr)?; + udp6_send_datagram(proc, sock_idx, buf, dst_ip, dst_port) + } + SocketDomain::Unix => { + if addr.len() < 3 { + return Err(Errno::EINVAL); + } + let path_bytes = &addr[2..]; + let resolved = if path_bytes.first().copied() == Some(0) { + if path_bytes.len() < 2 { + return Err(Errno::EINVAL); + } + path_bytes.to_vec() + } else { + let path_end = path_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(path_bytes.len()); + if path_end == 0 { + return Err(Errno::EINVAL); + } + resolve_namespace_path( + proc, + host, + &path_bytes[..path_end], + PathResolveOptions::FOLLOW, + )? + .path + }; + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + let peer = registry.lookup(&resolved).ok_or(Errno::ECONNREFUSED)?; + if peer.pid != proc.pid { + return Err(Errno::ECONNREFUSED); + } + let peer_idx = peer.sock_idx; + unix_dgram_send_to_sock(proc, sock_idx, peer_idx, buf) + } + }; + finish_datagram_send(proc, flags, result) } /// Receive a message from a socket with sender address. @@ -7603,11 +10102,11 @@ pub fn sys_recvfrom( _host: &mut dyn HostIO, fd: i32, buf: &mut [u8], - _flags: u32, + flags: u32, addr_buf: &mut [u8], ) -> Result<(usize, usize), Errno> { use crate::socket::{SocketDomain, SocketState, SocketType}; - use wasm_posix_shared::socket::MSG_PEEK; + use wasm_posix_shared::socket::{MSG_PEEK, MSG_TRUNC}; let entry = proc.fd_table.get(fd)?; let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; @@ -7619,10 +10118,13 @@ pub fn sys_recvfrom( // For STREAM sockets, delegate to sys_recv (musl routes recv→recvfrom) if sock.sock_type == SocketType::Stream { - let n = sys_recv(proc, _host, fd, buf, _flags)?; + let n = sys_recv(proc, _host, fd, buf, flags)?; return Ok((n, 0)); } - if sock.domain != SocketDomain::Inet { + if !matches!( + sock.domain, + SocketDomain::Inet | SocketDomain::Inet6 | SocketDomain::Unix + ) { return Err(Errno::EOPNOTSUPP); } if sock.shut_rd { @@ -7630,13 +10132,10 @@ pub fn sys_recvfrom( } let sock = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - let datagram_idx = sock.dgram_queue.iter().position(|d| { - if sock.state == SocketState::Connected { - d.src_addr == sock.peer_addr && d.src_port == sock.peer_port - } else { - true - } - }); + let datagram_idx = sock + .dgram_queue + .iter() + .position(|d| dgram_matches_connected_peer(sock, proc.pid, d)); if datagram_idx.is_none() { if sock.state == SocketState::Connected && sock.connect_error != 0 { let err = sock.connect_error; @@ -7646,23 +10145,48 @@ pub fn sys_recvfrom( return Err(Errno::EAGAIN); } let datagram_idx = datagram_idx.unwrap(); - let datagram = if _flags & MSG_PEEK != 0 { + let peek = flags & MSG_PEEK != 0; + let was_full = sock.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT; + let datagram = if peek { sock.dgram_queue[datagram_idx].clone() } else { sock.dgram_queue.remove(datagram_idx) }; + if !peek && was_full && sock.domain == SocketDomain::Unix { + crate::wakeup::push_datagram_writable(); + } // Copy data to buffer let copy_len = buf.len().min(datagram.data.len()); buf[..copy_len].copy_from_slice(&datagram.data[..copy_len]); + // Linux exposes the complete message length for Internet and Unix + // datagrams when MSG_TRUNC is requested, while still copying only what + // fits and discarding the unread tail of a non-peeked message. + let received_len = if flags & MSG_TRUNC != 0 { + datagram.data.len() + } else { + copy_len + }; - // Write sender sockaddr_in to addr_buf + // Write sender sockaddr to addr_buf let mut addr_written = 0; if !addr_buf.is_empty() { - addr_written = write_sockaddr_in(addr_buf, datagram.src_addr, datagram.src_port); + addr_written = match sock.domain { + SocketDomain::Inet => write_sockaddr_in(addr_buf, datagram.src_addr, datagram.src_port), + SocketDomain::Inet6 => { + write_sockaddr_in6(addr_buf, datagram.src_addr6, datagram.src_port) + } + SocketDomain::Unix => { + if addr_buf.len() >= 2 { + addr_buf[0] = 1; + addr_buf[1] = 0; + } + 2 + } + }; } - Ok((copy_len, addr_written)) + Ok((received_len, addr_written)) } /// Poll file descriptors for I/O readiness. @@ -7678,7 +10202,8 @@ pub fn sys_poll( return Ok(ready); } - if proc.signals.deliverable() != 0 && !proc.signals.should_restart() { + let tid = crate::process_table::current_tid(); + if proc.deliverable_for(tid) != 0 && !proc.should_restart_for(tid) { return Err(Errno::EINTR); } Err(Errno::EAGAIN) @@ -7689,6 +10214,7 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) use wasm_posix_shared::poll::*; let mut ready_count = 0i32; + let tid = crate::process_table::current_tid(); for pollfd in fds.iter_mut() { pollfd.revents = 0; @@ -7722,11 +10248,17 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) match ofd.file_type { FileType::EventFd => { let efd_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(Some(efd)) = proc.eventfds.get(efd_idx) { - if pollfd.events & POLLIN != 0 && efd.counter > 0 { + if let Some((counter, semaphore_room)) = + crate::descriptor_backing::with_eventfds(|table| { + table + .get(efd_idx) + .map(|efd| (efd.counter, efd.counter < u64::MAX - 1)) + }) + { + if pollfd.events & POLLIN != 0 && counter > 0 { revents |= POLLIN; } - if pollfd.events & POLLOUT != 0 && efd.counter < u64::MAX - 1 { + if pollfd.events & POLLOUT != 0 && semaphore_room { revents |= POLLOUT; } } @@ -7736,17 +10268,21 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) } FileType::TimerFd => { let tfd_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(Some(tfd)) = proc.timerfds.get(tfd_idx) { + if let Some(expirations) = crate::descriptor_backing::with_timerfds(|table| { + table.get(tfd_idx).map(|tfd| tfd.expirations) + }) { // Check if timer has expired (lazy: just check expirations counter) - if pollfd.events & POLLIN != 0 && tfd.expirations > 0 { + if pollfd.events & POLLIN != 0 && expirations > 0 { revents |= POLLIN; } } } FileType::SignalFd => { let sfd_idx = (-(ofd.host_handle + 1)) as usize; - if let Some(Some(sfd)) = proc.signalfds.get(sfd_idx) { - let matching = proc.signals.pending_mask() & sfd.mask; + if let Some(mask) = crate::descriptor_backing::with_signalfds(|table| { + table.get(sfd_idx).map(|sfd| sfd.mask) + }) { + let matching = proc.pending_for(tid) & mask; if pollfd.events & POLLIN != 0 && matching != 0 { revents |= POLLIN; } @@ -7881,15 +10417,34 @@ fn poll_check(proc: &mut Process, host: &mut dyn HostIO, fds: &mut [WasmPollFd]) if pollfd.events & POLLIN != 0 && sock.shut_rd { revents |= POLLIN; } else if pollfd.events & POLLIN != 0 - && sock.dgram_queue.iter().any(|d| { - sock.state != SocketState::Connected - || (d.src_addr == sock.peer_addr - && d.src_port == sock.peer_port) - }) + && sock + .dgram_queue + .iter() + .any(|d| dgram_matches_connected_peer(sock, proc.pid, d)) { revents |= POLLIN; } - if pollfd.events & POLLOUT != 0 && !sock.shut_wr { + let unix_peer_queue_full = sock.domain + == crate::socket::SocketDomain::Unix + && sock.state == SocketState::Connected + && sock + .peer_idx + .and_then(|peer_idx| proc.sockets.get(peer_idx)) + .is_some_and(|peer| { + peer.domain == crate::socket::SocketDomain::Unix + && peer.sock_type == SocketType::Dgram + && matches!( + peer.state, + SocketState::Bound | SocketState::Connected + ) + && !peer.shut_rd + && unix_dgram_target_accepts_sender(peer, sock_idx) + && peer.dgram_queue.len() >= UDP_DATAGRAM_QUEUE_LIMIT + }); + if pollfd.events & POLLOUT != 0 + && !sock.shut_wr + && !unix_peer_queue_full + { revents |= POLLOUT; } } @@ -8014,18 +10569,23 @@ pub fn sys_usleep(_proc: &mut Process, host: &mut dyn HostIO, usec: u32) -> Resu host.host_nanosleep(sec, nsec) } -/// Resolve a path relative to a directory fd. -/// - Absolute paths: returned as-is (dirfd ignored) -/// - AT_FDCWD: resolved relative to cwd -/// - Real dirfd: resolved relative to the directory's stored path -fn resolve_at_path(proc: &Process, dirfd: i32, path: &[u8]) -> Result, Errno> { +/// Resolve a path relative to a directory fd through the global namespace. +/// Absolute paths ignore `dirfd`; relative paths use either cwd or the +/// canonical path captured by the directory OFD. +fn resolve_at_path( + proc: &Process, + host: &mut dyn HostIO, + dirfd: i32, + path: &[u8], + options: PathResolveOptions, +) -> Result { use wasm_posix_shared::flags::AT_FDCWD; if !path.is_empty() && path[0] == b'/' { - return Ok(path.to_vec()); + return resolve_namespace_path(proc, host, path, options); } if dirfd == AT_FDCWD { - return Ok(crate::path::resolve_path(path, &proc.cwd)); + return resolve_namespace_path(proc, host, path, options); } let entry = proc.fd_table.get(dirfd)?; @@ -8033,8 +10593,9 @@ fn resolve_at_path(proc: &Process, dirfd: i32, path: &[u8]) -> Result Result { - let resolved = resolve_at_path(proc, dirfd, path)?; + let exclusive_create = oflags & O_CREAT != 0 && oflags & O_EXCL != 0; + let resolve_options = PathResolveOptions { + follow_final_symlink: oflags & O_NOFOLLOW == 0 && !exclusive_create, + allow_missing_final: oflags & O_CREAT != 0, + allow_missing_directory: false, + use_real_ids: false, + }; + let resolved_entry = resolve_at_path(proc, host, dirfd, path, resolve_options)?; + if resolved_entry + .stat + .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFLNK) + { + if exclusive_create { + return Err(Errno::EEXIST); + } + if oflags & O_NOFOLLOW != 0 { + return Err(Errno::ELOOP); + } + } + let resolved = resolved_entry.path; // /dev/fd/N and /dev/stdin|stdout|stderr — dup an existing fd if let Some(target_fd) = match_dev_fd(&resolved) { @@ -8120,17 +10700,17 @@ pub fn sys_openat( // /dev/tty — open controlling terminal if resolved == b"/dev/tty" { - for fd_i in 0..1024i32 { - if let Ok(entry) = proc.fd_table.get(fd_i as i32) { - if let Some(ofd) = proc.ofd_table.get(entry.ofd_ref.0) { - if ofd.file_type == FileType::PtySlave { - proc.ofd_table.inc_ref(entry.ofd_ref.0); - let fd_flags = oflags_to_fd_flags(oflags); - let fd = proc.fd_table.alloc(entry.ofd_ref, fd_flags)?; - return Ok(fd); - } - } - } + let pty_ofd = proc.fd_table.iter().find_map(|(_, entry)| { + proc.ofd_table + .get(entry.ofd_ref.0) + .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) + .then_some(entry.ofd_ref) + }); + if let Some(ofd_ref) = pty_ofd { + proc.ofd_table.inc_ref(ofd_ref.0); + let fd_flags = oflags_to_fd_flags(oflags); + let fd = proc.fd_table.alloc(ofd_ref, fd_flags)?; + return Ok(fd); } if let Ok(entry) = proc.fd_table.get(0) { let ofd_ref = entry.ofd_ref; @@ -8148,7 +10728,9 @@ pub fn sys_openat( } // Devfs (/dev, /dev/pts, etc.) — in-kernel directory listing - if crate::devfs::match_devfs_dir(&resolved).is_some() { + if !is_host_backed_devfs_path(&resolved) + && crate::devfs::match_devfs_dir(&resolved).is_some() + { return crate::devfs::devfs_open_dir(proc, resolved, oflags); } @@ -8222,7 +10804,12 @@ pub fn sys_fstatat( ) -> Result { use wasm_posix_shared::flags::AT_SYMLINK_NOFOLLOW; - let resolved = resolve_at_path(proc, dirfd, path)?; + let options = if flags & AT_SYMLINK_NOFOLLOW != 0 { + PathResolveOptions::NOFOLLOW + } else { + PathResolveOptions::FOLLOW + }; + let resolved = resolve_at_path(proc, host, dirfd, path, options)?.path; if let Some(dev) = match_virtual_device(&resolved) { return Ok(virtual_device_stat(dev, proc.euid, proc.egid)); } @@ -8246,36 +10833,22 @@ pub fn sys_fstatat( }); } if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; let follow = flags & AT_SYMLINK_NOFOLLOW == 0; return Ok(crate::procfs::procfs_stat(&entry, 0, follow)); } - if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { - return Ok(st); + if !is_host_backed_devfs_path(&resolved) { + if let Some(st) = crate::devfs::match_devfs_stat(&resolved, proc.euid, proc.egid) { + return Ok(st); + } } if let Some(st) = synthetic_file_stat(&resolved, proc.euid, proc.egid) { return Ok(st); } - // Check Unix socket registry + if let Some(st) = + unix_socket_path_stat(proc, host, &resolved, flags & AT_SYMLINK_NOFOLLOW == 0)? { - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - if registry.contains(&resolved) { - return Ok(WasmStat { - st_dev: 0, - st_ino: 0x554E5800, // "UNX\0" - st_mode: wasm_posix_shared::mode::S_IFSOCK | 0o755, - st_nlink: 1, - st_uid: proc.euid, - st_gid: proc.egid, - st_size: 0, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }); - } + return Ok(st); } // VFS is the source of truth for ownership: host_stat / host_lstat // already return the real uid/gid, so just propagate. @@ -8299,7 +10872,9 @@ pub fn sys_unlinkat( ) -> Result<(), Errno> { use wasm_posix_shared::flags::AT_REMOVEDIR; - let resolved = resolve_at_path(proc, dirfd, path)?; + let resolved = + resolve_at_path(proc, host, dirfd, path, PathResolveOptions::NOFOLLOW)?.path; + ensure_host_mutable_namespace_path(&resolved)?; check_parent_writable(proc, host, &resolved)?; if flags & AT_REMOVEDIR != 0 { check_sticky_child(proc, host, &resolved)?; @@ -8311,6 +10886,7 @@ pub fn sys_unlinkat( { let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; if registry.unregister(&resolved) { + crate::wakeup::push_datagram_writable(); match host.host_unlink(&resolved) { Ok(()) | Err(Errno::ENOENT) => return Ok(()), Err(e) => return Err(e), @@ -8342,7 +10918,9 @@ pub fn sys_mkdirat( path: &[u8], mode: u32, ) -> Result<(), Errno> { - let resolved = resolve_at_path(proc, dirfd, path)?; + let resolved = + resolve_at_path(proc, host, dirfd, path, PathResolveOptions::CREATE_DIRECTORY)?.path; + ensure_host_mutable_namespace_path(&resolved)?; let effective_mode = mode & !proc.umask; check_parent_writable(proc, host, &resolved)?; host.host_mkdir(&resolved, effective_mode)?; @@ -8358,15 +10936,32 @@ pub fn sys_renameat( newdirfd: i32, newpath: &[u8], ) -> Result<(), Errno> { - let old_resolved = resolve_at_path(proc, olddirfd, oldpath)?; - let new_resolved = resolve_at_path(proc, newdirfd, newpath)?; + let old_entry = + resolve_at_path(proc, host, olddirfd, oldpath, PathResolveOptions::NOFOLLOW)?; + let new_options = if old_entry + .stat + .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFDIR) + { + PathResolveOptions::CREATE_DIRECTORY + } else { + PathResolveOptions::CREATE_ENTRY + }; + let new_resolved = resolve_at_path(proc, host, newdirfd, newpath, new_options)?.path; + let old_resolved = old_entry.path; + ensure_host_mutable_namespace_path(&old_resolved)?; + ensure_host_mutable_namespace_path(&new_resolved)?; check_parent_writable(proc, host, &old_resolved)?; check_parent_writable(proc, host, &new_resolved)?; check_sticky_child(proc, host, &old_resolved)?; if host.host_lstat(&new_resolved).is_ok() { check_sticky_child(proc, host, &new_resolved)?; } - host.host_rename(&old_resolved, &new_resolved) + host.host_rename(&old_resolved, &new_resolved)?; + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + if registry.rename_path(&old_resolved, &new_resolved) { + crate::wakeup::push_datagram_writable(); + } + Ok(()) } /// tcgetattr -- get terminal attributes (custom syscall 70). @@ -9220,27 +11815,7 @@ pub fn sys_eventfd2(proc: &mut Process, initval: u32, flags: u32) -> Result { - proc.eventfds[i] = Some(state); - i - } - None => { - let i = proc.eventfds.len(); - proc.eventfds.push(Some(state)); - i - } - } - }; + let efd_idx = crate::descriptor_backing::with_eventfds(|table| table.alloc(state)); // Eventfd handle is negative: -(efd_idx + 1) let efd_handle = -((efd_idx as i64) + 1); @@ -9263,7 +11838,7 @@ pub fn sys_eventfd2(proc: &mut Process, initval: u32, flags: u32) -> Result Ok(fd), Err(e) => { proc.ofd_table.dec_ref(ofd_idx); - proc.eventfds[efd_idx] = None; + crate::descriptor_backing::with_eventfds(|table| table.release(efd_idx)); Err(e) } } @@ -9543,26 +12118,7 @@ pub fn sys_timerfd_create(proc: &mut Process, clock_id: u32, flags: u32) -> Resu expirations: 0, }; - let tfd_idx = { - let mut found = None; - for (i, slot) in proc.timerfds.iter().enumerate() { - if slot.is_none() { - found = Some(i); - break; - } - } - match found { - Some(i) => { - proc.timerfds[i] = Some(state); - i - } - None => { - let i = proc.timerfds.len(); - proc.timerfds.push(Some(state)); - i - } - } - }; + let tfd_idx = crate::descriptor_backing::with_timerfds(|table| table.alloc(state)); let handle = -((tfd_idx as i64) + 1); let mut status_flags = O_RDWR; @@ -9580,7 +12136,7 @@ pub fn sys_timerfd_create(proc: &mut Process, clock_id: u32, flags: u32) -> Resu Ok(fd) => Ok(fd), Err(e) => { proc.ofd_table.dec_ref(ofd_idx); - proc.timerfds[tfd_idx] = None; + crate::descriptor_backing::with_timerfds(|table| table.release(tfd_idx)); Err(e) } } @@ -9606,51 +12162,67 @@ pub fn sys_timerfd_settime( return Err(Errno::EINVAL); } let tfd_idx = (-(ofd.host_handle + 1)) as usize; - let tfd = proc - .timerfds - .get_mut(tfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - - let old = ( - tfd.interval_sec, - tfd.interval_nsec, - tfd.value_sec, - tfd.value_nsec, - ); const TFD_TIMER_ABSTIME: u32 = 1; - if value_sec == 0 && value_nsec == 0 { - // Disarm the timer - tfd.interval_sec = 0; - tfd.interval_nsec = 0; - tfd.value_sec = 0; - tfd.value_nsec = 0; - tfd.expirations = 0; + // The clock import is an external callback boundary. Snapshot the timer's + // immutable clock id under the backing lock, release it for the host call, + // then reacquire only for the final linearizable state update. Holding the + // non-reentrant backing spinlock across HostIO would deadlock if a host + // implementation ever called back into timerfd handling. + let relative_now = if value_sec != 0 || value_nsec != 0 { + if flags & TFD_TIMER_ABSTIME == 0 { + let clock_id = crate::descriptor_backing::with_timerfds(|table| { + table + .get(tfd_idx) + .map(|tfd| tfd.clock_id) + .ok_or(Errno::EBADF) + })?; + Some(host.host_clock_gettime(clock_id)?) + } else { + None + } } else { - tfd.interval_sec = interval_sec; - tfd.interval_nsec = interval_nsec; - if flags & TFD_TIMER_ABSTIME != 0 { - tfd.value_sec = value_sec; - tfd.value_nsec = value_nsec; + None + }; + + crate::descriptor_backing::with_timerfds(|table| { + let tfd = table.get_mut(tfd_idx).ok_or(Errno::EBADF)?; + let old = ( + tfd.interval_sec, + tfd.interval_nsec, + tfd.value_sec, + tfd.value_nsec, + ); + + if value_sec == 0 && value_nsec == 0 { + tfd.interval_sec = 0; + tfd.interval_nsec = 0; + tfd.value_sec = 0; + tfd.value_nsec = 0; + tfd.expirations = 0; } else { - // Relative: add current time - let clock_id = tfd.clock_id; - let (now_sec, now_nsec) = host.host_clock_gettime(clock_id)?; - let mut total_nsec = now_nsec + value_nsec; - let mut total_sec = now_sec + value_sec; - if total_nsec >= 1_000_000_000 { - total_sec += total_nsec / 1_000_000_000; - total_nsec %= 1_000_000_000; + tfd.interval_sec = interval_sec; + tfd.interval_nsec = interval_nsec; + if flags & TFD_TIMER_ABSTIME != 0 { + tfd.value_sec = value_sec; + tfd.value_nsec = value_nsec; + } else { + let (now_sec, now_nsec) = relative_now.ok_or(Errno::EIO)?; + let mut total_nsec = now_nsec + value_nsec; + let mut total_sec = now_sec + value_sec; + if total_nsec >= 1_000_000_000 { + total_sec += total_nsec / 1_000_000_000; + total_nsec %= 1_000_000_000; + } + tfd.value_sec = total_sec; + tfd.value_nsec = total_nsec; } - tfd.value_sec = total_sec; - tfd.value_nsec = total_nsec; + tfd.expirations = 0; } - tfd.expirations = 0; - } - Ok(old) + Ok(old) + }) } /// timerfd_gettime — get remaining time until next expiration. @@ -9665,19 +12237,27 @@ pub fn sys_timerfd_gettime( return Err(Errno::EINVAL); } let tfd_idx = (-(ofd.host_handle + 1)) as usize; - let tfd = proc - .timerfds - .get(tfd_idx) - .and_then(|s| s.as_ref()) - .ok_or(Errno::EBADF)?; + let snapshot = crate::descriptor_backing::with_timerfds(|table| { + table.get(tfd_idx).map(|tfd| { + ( + tfd.clock_id, + tfd.interval_sec, + tfd.interval_nsec, + tfd.value_sec, + tfd.value_nsec, + ) + }) + }) + .ok_or(Errno::EBADF)?; + let (clock_id, interval_sec, interval_nsec, timer_sec, timer_nsec) = snapshot; - if tfd.value_sec == 0 && tfd.value_nsec == 0 { + if timer_sec == 0 && timer_nsec == 0 { return Ok((0, 0, 0, 0)); } - let (now_sec, now_nsec) = host.host_clock_gettime(tfd.clock_id)?; - let mut remain_sec = tfd.value_sec - now_sec; - let mut remain_nsec = tfd.value_nsec - now_nsec; + let (now_sec, now_nsec) = host.host_clock_gettime(clock_id)?; + let mut remain_sec = timer_sec - now_sec; + let mut remain_nsec = timer_nsec - now_nsec; if remain_nsec < 0 { remain_sec -= 1; remain_nsec += 1_000_000_000; @@ -9686,7 +12266,7 @@ pub fn sys_timerfd_gettime( remain_sec = 0; remain_nsec = 0; } - Ok((tfd.interval_sec, tfd.interval_nsec, remain_sec, remain_nsec)) + Ok((interval_sec, interval_nsec, remain_sec, remain_nsec)) } /// Helper: compute timerfd expirations lazily. @@ -9750,38 +12330,18 @@ pub fn sys_signalfd4(proc: &mut Process, fd: i32, mask: u64, flags: u32) -> Resu return Err(Errno::EINVAL); } let sfd_idx = (-(ofd.host_handle + 1)) as usize; - let sfd = proc - .signalfds - .get_mut(sfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - sfd.mask = mask; + crate::descriptor_backing::with_signalfds(|table| { + let sfd = table.get_mut(sfd_idx).ok_or(Errno::EBADF)?; + sfd.mask = mask; + Ok(()) + })?; return Ok(fd); } // Create new signalfd let state = SignalFdState { mask }; - let sfd_idx = { - let mut found = None; - for (i, slot) in proc.signalfds.iter().enumerate() { - if slot.is_none() { - found = Some(i); - break; - } - } - match found { - Some(i) => { - proc.signalfds[i] = Some(state); - i - } - None => { - let i = proc.signalfds.len(); - proc.signalfds.push(Some(state)); - i - } - } - }; + let sfd_idx = crate::descriptor_backing::with_signalfds(|table| table.alloc(state)); let handle = -((sfd_idx as i64) + 1); let mut status_flags = O_RDONLY; @@ -9799,7 +12359,7 @@ pub fn sys_signalfd4(proc: &mut Process, fd: i32, mask: u64, flags: u32) -> Resu Ok(fd) => Ok(fd), Err(e) => { proc.ofd_table.dec_ref(ofd_idx); - proc.signalfds[sfd_idx] = None; + crate::descriptor_backing::with_signalfds(|table| table.release(sfd_idx)); Err(e) } } @@ -9844,7 +12404,7 @@ pub fn sys_uname(buf: &mut [u8]) -> Result<(), Errno> { /// sysconf — get configurable system variables pub fn sys_sysconf(name: i32) -> Result { match name { - 0 => Ok(4096), // _SC_ARG_MAX + 0 => Ok(4 * 1024 * 1024), // _SC_ARG_MAX: host exec argv+env aggregate cap 1 => Ok(0), // _SC_CHILD_MAX (unspecified) 2 => Ok(100), // _SC_CLK_TCK 4 => Ok(1024), // _SC_OPEN_MAX @@ -9856,35 +12416,204 @@ pub fn sys_sysconf(name: i32) -> Result { } } -/// pathconf -- get configurable pathname variable values. -/// -/// Returns POSIX-required compile-time constants for the given name. -/// The path is not validated (we return the same values regardless). -pub fn sys_pathconf(_path: &[u8], name: i32) -> Result { - pathconf_value(name) +fn validate_pathconf_name(name: i32) -> Result<(), Errno> { + if wasm_posix_shared::pathconf::ABI_NAMES + .iter() + .any(|(_, value)| *value == name) + { + Ok(()) + } else { + Err(Errno::EINVAL) + } } -/// fpathconf -- get configurable pathname variable values for an open fd. -pub fn sys_fpathconf(proc: &Process, fd: i32, name: i32) -> Result { - let _ = proc.fd_table.get(fd)?; - pathconf_value(name) -} +fn filesystem_pathconf_value( + name: i32, + supports_symlinks: bool, + timestamp_resolution_ns: Option, +) -> Result, Errno> { + use wasm_posix_shared::pathconf as pc; -fn pathconf_value(name: i32) -> Result { match name { - 1 => Ok(14), // _PC_LINK_MAX - 2 => Ok(13), // _PC_MAX_CANON - 3 => Ok(255), // _PC_MAX_INPUT - 4 => Ok(255), // _PC_NAME_MAX - 5 => Ok(4096), // _PC_PATH_MAX - 6 => Ok(4096), // _PC_PIPE_BUF - 7 => Ok(1), // _PC_CHOWN_RESTRICTED - 8 => Ok(1), // _PC_NO_TRUNC - 9 => Ok(0), // _PC_VDISABLE + pc::LINK_MAX => Ok(None), + pc::NAME_MAX => Ok(Some(NAMESPACE_NAME_MAX as i64)), + pc::PATH_MAX => Ok(Some(NAMESPACE_PATH_MAX as i64)), + // Authorization is enforced by the kernel for every namespace + // backend, including backends without persistent ownership metadata. + pc::CHOWN_RESTRICTED => Ok(Some(1)), + pc::NO_TRUNC => Ok(Some(1)), + pc::SYNC_IO + | pc::PRIO_IO + | pc::FILESIZEBITS + | pc::REC_INCR_XFER_SIZE + | pc::REC_MAX_XFER_SIZE + | pc::REC_MIN_XFER_SIZE + | pc::REC_XFER_ALIGN + | pc::ALLOC_SIZE_MIN + | pc::SYMLINK_MAX + | pc::FALLOC => Ok(None), + pc::POSIX2_SYMLINKS => Ok(supports_symlinks.then_some(1)), + pc::TEXTDOMAIN_MAX => Ok(Some(NAMESPACE_NAME_MAX as i64)), + pc::TIMESTAMP_RESOLUTION => Ok(timestamp_resolution_ns), + pc::MAX_CANON + | pc::MAX_INPUT + | pc::PIPE_BUF + | pc::VDISABLE + | pc::SOCK_MAXBUF + | pc::ASYNC_IO => Err(Errno::EINVAL), _ => Err(Errno::EINVAL), } } +fn terminal_pathconf_value(name: i32) -> Result, Errno> { + use wasm_posix_shared::pathconf as pc; + + match name { + pc::MAX_CANON | pc::MAX_INPUT => Ok(None), + pc::VDISABLE => Ok(Some(0)), + _ => virtual_filesystem_pathconf_value(name), + } +} + +fn virtual_filesystem_pathconf_value(name: i32) -> Result, Errno> { + filesystem_pathconf_value(name, false, None) +} + +/// `pathconf` validates and follows the pathname through the global namespace, +/// then asks the selected backend for values that depend on that filesystem. +/// `None` is a successful indeterminate/unsupported-option result and must be +/// returned to libc as `-1` without changing errno. +pub fn sys_pathconf( + proc: &Process, + host: &mut dyn HostIO, + path: &[u8], + name: i32, +) -> Result, Errno> { + validate_pathconf_name(name)?; + let resolved_entry = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?; + if name == wasm_posix_shared::pathconf::ASYNC_IO + && resolved_entry + .stat + .is_some_and(|stat| stat.st_mode & S_IFMT == S_IFREG) + { + // Kandelo's musl implements AIO through guest pthreads over the same + // pread/pwrite/fsync path on every host backend. + return Ok(Some(1)); + } + let resolved = resolved_entry.path; + + if let Some(fd) = match_dev_fd(&resolved) { + return sys_fpathconf(proc, host, fd, name); + } + if let Some(crate::procfs::ProcfsEntry::FdLink(pid, fd)) = + crate::procfs::match_procfs(&resolved, proc.pid) + { + if pid == proc.pid { + return sys_fpathconf(proc, host, fd, name); + } + } + + if resolved == b"/dev/tty" { + let controlling_fd = proc + .fd_table + .iter() + .find_map(|(fd, entry)| { + proc.ofd_table + .get(entry.ofd_ref.0) + .is_some_and(|ofd| ofd.file_type == FileType::PtySlave) + .then_some(fd) + }) + .or_else(|| proc.fd_table.get(0).ok().map(|_| 0)); + return controlling_fd + .ok_or(Errno::ENXIO) + .and_then(|fd| sys_fpathconf(proc, host, fd, name)); + } + if resolved == b"/dev/ptmx" || resolved.starts_with(b"/dev/pts/") { + return terminal_pathconf_value(name); + } + if is_procfs_namespace_path(&resolved) + || (is_devfs_namespace_path(&resolved) && !is_host_backed_devfs_path(&resolved)) + { + return virtual_filesystem_pathconf_value(name); + } + if synthetic_file_content(&resolved).is_some() { + return host.host_pathconf(b"/", name); + } + + host.host_pathconf(&resolved, name) +} + +/// `fpathconf` uses the live OFD/backend identity. It never re-resolves the +/// remembered pathname, so an open file remains queryable after rename or +/// unlink. +pub fn sys_fpathconf( + proc: &Process, + host: &mut dyn HostIO, + fd: i32, + name: i32, +) -> Result, Errno> { + use wasm_posix_shared::pathconf as pc; + + validate_pathconf_name(name)?; + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + let file_type = ofd.file_type; + let host_handle = ofd.host_handle; + let path = ofd.path.clone(); + + if name == pc::ASYNC_IO + && (file_type == FileType::MemFd + || (file_type == FileType::Regular && host_handle < 0)) + { + return Ok(Some(1)); + } + + if synthetic_file_content(&path).is_some() { + // Synthetic dynamic files live in the root mount's namespace even + // though they have no host handle of their own. Match pathconf and + // the existing statfs/fstatfs policy by querying the root backend. + return host.host_pathconf(b"/", name); + } + + match file_type { + FileType::Pipe => { + if name == pc::PIPE_BUF { + return Ok((host_handle < 0).then_some(crate::pipe::PIPE_BUF as i64)); + } + Err(Errno::EINVAL) + } + FileType::PtyMaster | FileType::PtySlave => terminal_pathconf_value(name), + FileType::Socket => { + if name == pc::SOCK_MAXBUF { + // Socket buffering varies across in-kernel and host/browser + // backends; no authoritative maximum is enforced globally. + Ok(None) + } else { + Err(Errno::EINVAL) + } + } + FileType::MemFd => filesystem_pathconf_value(name, false, None), + FileType::Regular | FileType::Directory | FileType::CharDevice => { + if file_type == FileType::CharDevice + && matches!(path.as_slice(), b"/dev/stdin" | b"/dev/stdout" | b"/dev/stderr") + { + terminal_pathconf_value(name) + } else if is_procfs_namespace_path(&path) + || (is_devfs_namespace_path(&path) && !is_host_backed_devfs_path(&path)) + { + virtual_filesystem_pathconf_value(name) + } else if host_handle >= 0 { + host.host_fpathconf(host_handle, name) + } else { + virtual_filesystem_pathconf_value(name) + } + } + FileType::EventFd | FileType::Epoll | FileType::TimerFd | FileType::SignalFd => { + Err(Errno::EINVAL) + } + } +} + /// ftruncate -- truncate a file to a specified length. pub fn sys_ftruncate( proc: &mut Process, @@ -9897,39 +12626,56 @@ pub fn sys_ftruncate( } let entry = proc.fd_table.get(fd)?; let ofd_idx = entry.ofd_ref.0; - let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + let (file_type, status_flags, host_handle) = { + let ofd = proc.ofd_table.get(ofd_idx).ok_or(Errno::EBADF)?; + (ofd.file_type, ofd.status_flags, ofd.host_handle) + }; // Must be a regular file or memfd - if ofd.file_type != FileType::Regular && ofd.file_type != FileType::MemFd { + if file_type != FileType::Regular && file_type != FileType::MemFd { return Err(Errno::EINVAL); } // Must be writable (O_WRONLY or O_RDWR) - let access = ofd.status_flags & O_ACCMODE; + let access = status_flags & O_ACCMODE; if access == O_RDONLY { return Err(Errno::EINVAL); } - // MemFd: truncate in-memory buffer - if ofd.file_type == FileType::MemFd { - let memfd_idx = (-(ofd.host_handle + 1)) as usize; - let data = proc - .memfds - .get_mut(memfd_idx) - .and_then(|s| s.as_mut()) - .ok_or(Errno::EBADF)?; - data.resize(length as usize, 0); - return Ok(()); - } + let current_size = if file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + crate::descriptor_backing::with_memfds(|table| { + let backing = table.get(memfd_idx).ok_or(Errno::EBADF)?; + u64::try_from(backing.data.len()).map_err(|_| Errno::EOVERFLOW) + })? + } else { + host.host_fstat(host_handle)?.st_size + }; - // RLIMIT_FSIZE: check if truncate target exceeds file size limit - let fsize_limit = proc.rlimits[1][0]; // RLIMIT_FSIZE soft limit - if fsize_limit != u64::MAX && (length as u64) > fsize_limit { - proc.signals.raise(wasm_posix_shared::signal::SIGXFSZ); + // RLIMIT_FSIZE forbids an operation that would grow a file beyond the + // limit. Shrinking (or retaining) an already-oversized file is permitted. + let fsize_limit = proc.rlimits[RLIMIT_FSIZE as usize][0]; + if fsize_limit != RLIM_INFINITY + && (length as u64) > current_size + && (length as u64) > fsize_limit + { + raise_fsize_signal_for_caller(proc); return Err(Errno::EFBIG); } - host.host_ftruncate(ofd.host_handle, length) + // MemFd: truncate in-memory buffer + if file_type == FileType::MemFd { + let memfd_idx = (-(host_handle + 1)) as usize; + let new_len = usize::try_from(length).map_err(|_| Errno::EFBIG)?; + crate::descriptor_backing::with_memfds(|table| { + let backing = table.get_mut(memfd_idx).ok_or(Errno::EBADF)?; + backing.data.resize(new_len, 0); + Ok(()) + })?; + return Ok(()); + } + + host.host_ftruncate(host_handle, length) } /// fallocate -- ensure space is allocated for a file region. @@ -9944,11 +12690,11 @@ pub fn sys_fallocate( if offset < 0 || len <= 0 { return Err(Errno::EINVAL); } - let required = offset + len; + let required = offset.checked_add(len).ok_or(Errno::EFBIG)?; // Get current file size via fstat let stat = sys_fstat(proc, host, fd)?; - let current_size = stat.st_size as i64; + let current_size = i64::try_from(stat.st_size).map_err(|_| Errno::EOVERFLOW)?; // Only extend if needed — fallocate never shrinks if required > current_size { @@ -10032,9 +12778,8 @@ pub fn sys_fchown( match ofd.file_type { FileType::Regular | FileType::Directory => { - if proc.euid != 0 { - return Err(Errno::EPERM); - } + let st = host.host_fstat(ofd.host_handle)?; + let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; host.host_fchown(ofd.host_handle, uid, gid) } // Match sys_fchmod above — accept the call on all fd types so @@ -10052,15 +12797,22 @@ pub fn sys_writev( fd: i32, buffers: &[&[u8]], ) -> Result { + let requested_len = checked_iovec_len(buffers)?; + let writable_len = write_operation_budget(proc, host, fd, None, requested_len)?; let mut total = 0usize; for buf in buffers { + if total == writable_len { + break; + } if buf.is_empty() { continue; } - match sys_write(proc, host, fd, buf) { + let operation_remaining = writable_len - total; + let attempted = buf.len().min(operation_remaining); + match sys_write(proc, host, fd, &buf[..attempted]) { Ok(n) => { total += n; - if n < buf.len() { + if n < attempted || total == writable_len { break; // Short write, stop } } @@ -10151,8 +12903,6 @@ pub fn sys_setrlimit(proc: &mut Process, resource: u32, soft: u64, hard: u64) -> } /// faccessat -- check file accessibility relative to directory fd. -/// -/// Only AT_FDCWD and absolute paths are currently supported. pub fn sys_faccessat( proc: &mut Process, host: &mut dyn HostIO, @@ -10161,31 +12911,26 @@ pub fn sys_faccessat( amode: u32, flags: u32, ) -> Result<(), Errno> { - let resolved = resolve_at_path(proc, dirfd, path)?; if amode & !(R_OK | W_OK | X_OK) != 0 { return Err(Errno::EINVAL); } if flags & !(AT_EACCESS | wasm_posix_shared::flags::AT_SYMLINK_NOFOLLOW) != 0 { return Err(Errno::EINVAL); } - if match_virtual_device(&resolved).is_some() - || match_dev_fd(&resolved).is_some() - || crate::devfs::match_devfs_dir(&resolved).is_some() - { - return Ok(()); - } - if crate::procfs::match_procfs(&resolved, proc.pid).is_some() { - if amode & 0o2 != 0 { - return Err(Errno::EACCES); - } - return Ok(()); - } - check_search_path(proc, host, &resolved)?; - let st = if flags & wasm_posix_shared::flags::AT_SYMLINK_NOFOLLOW != 0 { - host.host_lstat(&resolved)? + let mut options = if flags & wasm_posix_shared::flags::AT_SYMLINK_NOFOLLOW != 0 { + PathResolveOptions::NOFOLLOW } else { - host.host_stat(&resolved)? + PathResolveOptions::FOLLOW }; + options.use_real_ids = flags & AT_EACCESS == 0; + let resolved = resolve_at_path(proc, host, dirfd, path, options)?; + if amode & W_OK != 0 + && (is_procfs_namespace_path(&resolved.path) + || synthetic_file_content(&resolved.path).is_some()) + { + return Err(Errno::EACCES); + } + let st = resolved.stat.ok_or(Errno::ENOENT)?; let (uid, gid) = if flags & AT_EACCESS != 0 { (proc.euid, proc.egid) } else { @@ -10206,7 +12951,9 @@ pub fn sys_fchmodat( mode: u32, _flags: u32, ) -> Result<(), Errno> { - let resolved = resolve_at_path(proc, dirfd, path)?; + let resolved = + resolve_at_path(proc, host, dirfd, path, PathResolveOptions::FOLLOW)?.path; + ensure_host_mutable_namespace_path(&resolved)?; check_search_path(proc, host, &resolved)?; let st = host.host_stat(&resolved)?; check_owner_or_root(proc, &st)?; @@ -10221,14 +12968,29 @@ pub fn sys_fchownat( path: &[u8], uid: u32, gid: u32, - _flags: u32, + flags: u32, ) -> Result<(), Errno> { - let resolved = resolve_at_path(proc, dirfd, path)?; - if proc.euid != 0 { - return Err(Errno::EPERM); + use wasm_posix_shared::flags::AT_SYMLINK_NOFOLLOW; + + if flags & !AT_SYMLINK_NOFOLLOW != 0 { + return Err(Errno::EINVAL); + } + let nofollow = flags & AT_SYMLINK_NOFOLLOW != 0; + let options = if nofollow { + PathResolveOptions::NOFOLLOW + } else { + PathResolveOptions::FOLLOW + }; + let resolved = resolve_at_path(proc, host, dirfd, path, options)?; + ensure_host_mutable_namespace_path(&resolved.path)?; + check_search_path(proc, host, &resolved.path)?; + let st = resolved.stat.ok_or(Errno::ENOENT)?; + let (uid, gid) = prepare_chown_ids(proc, &st, uid, gid)?; + if nofollow { + host.host_lchown(&resolved.path, uid, gid) + } else { + host.host_chown(&resolved.path, uid, gid) } - check_search_path(proc, host, &resolved)?; - host.host_chown(&resolved, uid, gid) } /// linkat -- create hard link relative to directory fds. @@ -10239,10 +13001,19 @@ pub fn sys_linkat( oldpath: &[u8], newdirfd: i32, newpath: &[u8], - _flags: u32, + flags: u32, ) -> Result<(), Errno> { - let old_resolved = resolve_at_path(proc, olddirfd, oldpath)?; - let new_resolved = resolve_at_path(proc, newdirfd, newpath)?; + const AT_SYMLINK_FOLLOW: u32 = 0x400; + let old_options = if flags & AT_SYMLINK_FOLLOW != 0 { + PathResolveOptions::FOLLOW + } else { + PathResolveOptions::NOFOLLOW + }; + let old_resolved = resolve_at_path(proc, host, olddirfd, oldpath, old_options)?.path; + let new_resolved = + resolve_at_path(proc, host, newdirfd, newpath, PathResolveOptions::CREATE_ENTRY)?.path; + ensure_host_mutable_namespace_path(&old_resolved)?; + ensure_host_mutable_namespace_path(&new_resolved)?; check_search_path(proc, host, &old_resolved)?; check_parent_writable(proc, host, &new_resolved)?; host.host_link(&old_resolved, &new_resolved) @@ -10259,7 +13030,9 @@ pub fn sys_symlinkat( newdirfd: i32, linkpath: &[u8], ) -> Result<(), Errno> { - let resolved_link = resolve_at_path(proc, newdirfd, linkpath)?; + let resolved_link = + resolve_at_path(proc, host, newdirfd, linkpath, PathResolveOptions::CREATE_ENTRY)?.path; + ensure_host_mutable_namespace_path(&resolved_link)?; check_parent_writable(proc, host, &resolved_link)?; host.host_symlink(target, &resolved_link) } @@ -10272,10 +13045,12 @@ pub fn sys_readlinkat( path: &[u8], buf: &mut [u8], ) -> Result { - let resolved = resolve_at_path(proc, dirfd, path)?; + let resolved = + resolve_at_path(proc, host, dirfd, path, PathResolveOptions::NOFOLLOW)?.path; // Procfs symlinks — /proc/self, /proc/self/fd/N, /proc/self/cwd, etc. if let Some(entry) = crate::procfs::match_procfs(&resolved, proc.pid) { + crate::procfs::validate_entry(proc, &entry)?; if entry.is_symlink() { return crate::procfs::procfs_readlink(proc, &entry, buf); } @@ -10397,7 +13172,8 @@ pub fn sys_select( if ready > 0 || timeout_ms == 0 { return Ok(ready); } - if proc.signals.deliverable() != 0 && !proc.signals.should_restart() { + let tid = crate::process_table::current_tid(); + if proc.deliverable_for(tid) != 0 && !proc.should_restart_for(tid) { return Err(Errno::EINTR); } Err(Errno::EAGAIN) @@ -10492,20 +13268,21 @@ pub fn can_query_sched(sender_euid: u32, target_uid: u32, target_euid: u32) -> b /// getrusage -- get resource usage (simulated). /// /// Returns mostly zeroed rusage struct. Wasm runtimes don't expose -/// CPU/memory usage metrics, so we can't track actual resource usage. The struct is -/// 144 bytes: 2 x timeval (16 bytes each) + 14 x i64. +/// CPU/memory usage metrics, so we can't track actual resource usage. The wire +/// size is owned by `wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE`. pub fn sys_getrusage(_proc: &mut Process, who: i32, buf: &mut [u8]) -> Result<(), Errno> { use wasm_posix_shared::rusage::{RUSAGE_CHILDREN, RUSAGE_SELF}; + let wire_size = wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE as usize; if who != RUSAGE_SELF && who != RUSAGE_CHILDREN { return Err(Errno::EINVAL); } - if buf.len() < 144 { + if buf.len() < wire_size { return Err(Errno::EINVAL); } // Zero the entire struct — all fields are 0 // In a more complete implementation, ru_utime could track elapsed time - buf[..144].fill(0); + buf[..wire_size].fill(0); Ok(()) } @@ -10544,126 +13321,16 @@ pub fn sys_setpriority(proc: &mut Process, which: i32, who: u32, prio: i32) -> R /// realpath -- resolve a pathname to a canonical absolute form. /// -/// Resolves the path against cwd, normalizes `.` and `..` components, -/// and resolves symlinks by walking each path component with lstat/readlink. -/// Returns ELOOP after 40 symlink resolutions. +/// Uses the same component walker as pathname syscalls so mount crossings, +/// synthetic namespaces, trailing components, and symlink limits all share +/// one definition of canonical identity. pub fn sys_realpath( proc: &mut Process, host: &mut dyn HostIO, path: &[u8], buf: &mut [u8], ) -> Result { - use crate::path::{normalize_path, resolve_path}; - - if path.is_empty() { - return Err(Errno::ENOENT); - } - - const MAX_SYMLINKS: u32 = 40; - let mut symlink_count: u32 = 0; - - // Make absolute and normalize - let absolute = resolve_path(path, &proc.cwd); - let normalized = normalize_path(&absolute); - - // Split into components and resolve each - let mut resolved = Vec::new(); - resolved.push(b'/'); - - // Collect components (skip empty from split) - let components: Vec<&[u8]> = normalized[1..] - .split(|&b| b == b'/') - .filter(|c| !c.is_empty()) - .collect(); - - let mut i = 0; - let mut remaining_components: Vec> = components.iter().map(|c| c.to_vec()).collect(); - - while i < remaining_components.len() { - let component = remaining_components[i].clone(); - i += 1; - - // Build the candidate path: resolved + "/" + component - let mut candidate = resolved.clone(); - if candidate.len() > 1 { - candidate.push(b'/'); - } - candidate.extend_from_slice(&component); - - // lstat to check if this component is a symlink - check_search_path(proc, host, &candidate)?; - match host.host_lstat(&candidate) { - Ok(stat) => { - // S_IFLNK = 0o120000 = 0xA000 - if (stat.st_mode & 0o170000) == 0o120000 { - // It's a symlink — resolve it - symlink_count += 1; - if symlink_count > MAX_SYMLINKS { - return Err(Errno::ELOOP); - } - - let mut link_target = [0u8; 4096]; - let link_len = host.host_readlink(&candidate, &mut link_target)?; - let target = &link_target[..link_len]; - - if target.is_empty() { - return Err(Errno::ENOENT); - } - - // Collect remaining components after this one - let rest: Vec> = remaining_components[i..].to_vec(); - - if target[0] == b'/' { - // Absolute symlink: restart from root - resolved.clear(); - resolved.push(b'/'); - let target_norm = normalize_path(target); - let mut new_components: Vec> = target_norm[1..] - .split(|&b| b == b'/') - .filter(|c| !c.is_empty()) - .map(|c| c.to_vec()) - .collect(); - new_components.extend(rest); - remaining_components = new_components; - i = 0; - } else { - // Relative symlink: resolve relative to current resolved dir - let mut new_components: Vec> = target - .split(|&b| b == b'/') - .filter(|c| !c.is_empty()) - .map(|c| c.to_vec()) - .collect(); - new_components.extend(rest); - remaining_components = new_components; - i = 0; - } - } else { - // Not a symlink — add to resolved path - if component == b".." { - // Go up one level - if let Some(pos) = resolved.iter().rposition(|&b| b == b'/') { - if pos == 0 { - resolved.truncate(1); // stay at root - } else { - resolved.truncate(pos); - } - } - } else if component != b"." { - if resolved.len() > 1 { - resolved.push(b'/'); - } - resolved.extend_from_slice(&component); - } - } - } - Err(e) => return Err(e), - } - } - - // Final existence check - check_search_path(proc, host, &resolved)?; - host.host_stat(&resolved)?; - + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; let len = resolved.len(); if buf.len() < len { return Err(Errno::ERANGE); @@ -10730,7 +13397,8 @@ fn virtual_statfs_for_path(resolved: &[u8], pid: u32) -> Option { { return Some(procfs_statfs()); } - if crate::devfs::match_devfs_dir(resolved).is_some() + if (!is_host_backed_devfs_path(resolved) + && crate::devfs::match_devfs_dir(resolved).is_some()) || match_virtual_device(resolved).is_some() || resolved == b"/dev/ptmx" || resolved == b"/dev/tty" @@ -10756,8 +13424,7 @@ pub fn sys_statfs( host: &mut dyn HostIO, path: &[u8], ) -> Result { - let resolved = crate::path::resolve_path(path, &proc.cwd); - let _ = sys_stat(proc, host, path)?; + let resolved = resolve_namespace_path(proc, host, path, PathResolveOptions::FOLLOW)?.path; if let Some(statfs) = virtual_statfs_for_path(&resolved, proc.pid) { return Ok(statfs); @@ -10940,9 +13607,9 @@ pub fn sys_memfd_create(proc: &mut Process, name: &[u8], flags: u32) -> Result Result Ok(fd), + Err(err) => { + proc.ofd_table.dec_ref(ofd_idx); + crate::descriptor_backing::with_memfds(|table| table.release(memfd_idx)); + Err(err) + } + } } #[cfg(test)] @@ -10983,6 +13657,76 @@ mod tests { static UNIX_REGISTRY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); static THREAD_IDENTITY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + #[test] + fn socket_timeout_options_accept_time64_and_long64_numbers() { + use wasm_posix_shared::socket::{ + AF_INET, IPPROTO_TCP, SOCK_STREAM, SOL_SOCKET, SO_RCVTIMEO, + SO_RCVTIMEO_OLD, SO_SNDTIMEO, SO_SNDTIMEO_OLD, + }; + + assert_eq!( + canonical_socket_timeout_optname(SOL_SOCKET, SO_RCVTIMEO), + Some(SO_RCVTIMEO), + ); + assert_eq!( + canonical_socket_timeout_optname(SOL_SOCKET, SO_SNDTIMEO), + Some(SO_SNDTIMEO), + ); + assert_eq!( + canonical_socket_timeout_optname(SOL_SOCKET, SO_RCVTIMEO_OLD), + Some(SO_RCVTIMEO), + ); + assert_eq!( + canonical_socket_timeout_optname(SOL_SOCKET, SO_SNDTIMEO_OLD), + Some(SO_SNDTIMEO), + ); + assert_eq!(canonical_socket_timeout_optname(SOL_SOCKET, 19), None); + assert_eq!( + canonical_socket_timeout_optname(IPPROTO_TCP, SO_RCVTIMEO_OLD), + None, + ); + + let mut proc = Process::new(9037); + let mut host = MockHostIO::new(); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let recv_opt = + canonical_socket_timeout_optname(SOL_SOCKET, SO_RCVTIMEO_OLD).unwrap(); + let send_opt = + canonical_socket_timeout_optname(SOL_SOCKET, SO_SNDTIMEO_OLD).unwrap(); + + sys_setsockopt_timeout(&mut proc, fd, recv_opt, 1_250_000).unwrap(); + sys_setsockopt_timeout(&mut proc, fd, send_opt, 2_500_000).unwrap(); + + assert_eq!(sys_getsockopt_timeout(&proc, fd, recv_opt), Ok(1_250_000)); + assert_eq!(sys_getsockopt_timeout(&proc, fd, send_opt), Ok(2_500_000)); + } + + fn test_unix_addr(path: &[u8]) -> Vec { + let mut addr = vec![0; 2 + path.len() + 1]; + addr[0] = wasm_posix_shared::socket::AF_UNIX as u8; + addr[2..2 + path.len()].copy_from_slice(path); + addr + } + + fn bind_test_unix_dgram( + proc: &mut Process, + host: &mut dyn HostIO, + path: &[u8], + ) -> (i32, Vec) { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + let fd = sys_socket(proc, host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let addr = test_unix_addr(path); + sys_bind(proc, host, fd, &addr).unwrap(); + (fd, addr) + } + + fn test_socket_idx(proc: &Process, fd: i32) -> usize { + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + } + fn set_test_current_tid(tid: u32) { unsafe { (*crate::process_table::GLOBAL_PROCESS_TABLE.0.get()).set_current_tid(tid); @@ -11063,6 +13807,7 @@ mod tests { sigsuspend_signal: u32, sigsuspend_error: bool, clock_time: (i64, i64), + clock_error: Option, /// Per-path owner overrides for host_stat / host_lstat. Mirrors how a real /// host-side VFS owns ownership; tests use `set_file_with_owner` to seed. file_owners: std::collections::HashMap, (u32, u32)>, @@ -11072,6 +13817,8 @@ mod tests { handle_owners: std::collections::HashMap, handle_paths: std::collections::HashMap>, missing_paths: std::collections::HashSet>, + symlink_targets: std::collections::HashMap, Vec>, + lstat_paths: Vec>, statfs_by_path: std::collections::HashMap, WasmStatfs>, /// Recorded `(pid, bo_id, addr, len)` for every `gbm_bo_bind` call so /// the DRI mmap path can be asserted against. @@ -11086,6 +13833,23 @@ mod tests { /// Override for `gl_submit`'s return value (0 = success, negative /// = errno). Defaults to 0. gl_submit_rc: i32, + net_connect_result: Result<(), Errno>, + net_connect_status_result: Result<(), Errno>, + net_send_result: Result, + net_connect_calls: Vec<(i32, Vec, u16)>, + net_listen_calls: Vec<(i32, u16, [u8; 4])>, + chown_calls: Vec<(Vec, u32, u32)>, + lchown_calls: Vec<(Vec, u32, u32)>, + fchown_calls: Vec<(i64, u32, u32)>, + closed_handles: Vec, + closed_dir_handles: Vec, + seek_end: i64, + seek_calls: Vec<(i64, i64, u32)>, + stat_size: u64, + pathconf_result: Result, Errno>, + fpathconf_result: Result, Errno>, + pathconf_calls: Vec<(Vec, i32)>, + fpathconf_calls: Vec<(i64, i32)>, } impl MockHostIO { @@ -11098,17 +13862,37 @@ mod tests { sigsuspend_signal: 0, sigsuspend_error: false, clock_time: (1234567890, 123456789), + clock_error: None, file_owners: std::collections::HashMap::new(), file_modes: std::collections::HashMap::new(), handle_owners: std::collections::HashMap::new(), handle_paths: std::collections::HashMap::new(), missing_paths: std::collections::HashSet::new(), + symlink_targets: std::collections::HashMap::new(), + lstat_paths: Vec::new(), statfs_by_path: std::collections::HashMap::new(), gbm_bo_bind_calls: Vec::new(), gbm_bo_unbind_calls: Vec::new(), gl_unbind_calls: Vec::new(), gbm_bo_bind_rc: 0, gl_submit_rc: 0, + net_connect_result: Err(Errno::ECONNREFUSED), + net_connect_status_result: Err(Errno::ECONNREFUSED), + net_send_result: Err(Errno::ENOTCONN), + net_connect_calls: Vec::new(), + net_listen_calls: Vec::new(), + chown_calls: Vec::new(), + lchown_calls: Vec::new(), + fchown_calls: Vec::new(), + closed_handles: Vec::new(), + closed_dir_handles: Vec::new(), + seek_end: 0, + seek_calls: Vec::new(), + stat_size: 1024, + pathconf_result: Ok(Some(255)), + fpathconf_result: Ok(Some(4096)), + pathconf_calls: Vec::new(), + fpathconf_calls: Vec::new(), } } @@ -11143,7 +13927,14 @@ mod tests { self.missing_paths.insert(path.to_vec()); } - fn set_statfs(&mut self, path: &[u8], statfs: WasmStatfs) { + fn set_symlink(&mut self, path: &[u8], target: &[u8]) { + self.missing_paths.remove(path); + self.file_modes.insert(path.to_vec(), S_IFLNK | 0o777); + self.symlink_targets + .insert(path.to_vec(), target.to_vec()); + } + + fn set_statfs(&mut self, path: &[u8], statfs: WasmStatfs) { self.statfs_by_path.insert(path.to_vec(), statfs); } } @@ -11167,7 +13958,8 @@ mod tests { Ok(handle) } - fn host_close(&mut self, _handle: i64) -> Result<(), Errno> { + fn host_close(&mut self, handle: i64) -> Result<(), Errno> { + self.closed_handles.push(handle); Ok(()) } @@ -11182,8 +13974,13 @@ mod tests { Ok(buf.len()) } - fn host_seek(&mut self, _handle: i64, _offset: i64, _whence: u32) -> Result { - Ok(0) + fn host_seek(&mut self, handle: i64, offset: i64, whence: u32) -> Result { + self.seek_calls.push((handle, offset, whence)); + if whence == SEEK_END { + Ok(self.seek_end + offset) + } else { + Ok(offset) + } } fn host_fstat(&mut self, handle: i64) -> Result { @@ -11205,7 +14002,7 @@ mod tests { st_nlink: 1, st_uid: uid, st_gid: gid, - st_size: 1024, + st_size: self.stat_size, st_atime_sec: 0, st_atime_nsec: 0, st_mtime_sec: 0, @@ -11245,9 +14042,11 @@ mod tests { } fn host_lstat(&mut self, path: &[u8]) -> Result { - // Return S_IFLNK for paths containing "link" (for symlink tests), - // otherwise return regular file/directory mode. - let is_symlink = path.windows(4).any(|w| w == b"link"); + self.lstat_paths.push(path.to_vec()); + if self.missing_paths.contains(path) { + return Err(Errno::ENOENT); + } + let is_symlink = self.symlink_targets.contains_key(path); let mode = if is_symlink { S_IFLNK | 0o777 } else { @@ -11284,6 +14083,20 @@ mod tests { .unwrap_or_else(default_statfs)) } + fn host_pathconf(&mut self, path: &[u8], name: i32) -> Result, Errno> { + self.pathconf_calls.push((path.to_vec(), name)); + self.pathconf_result + } + + fn host_fpathconf( + &mut self, + handle: i64, + name: i32, + ) -> Result, Errno> { + self.fpathconf_calls.push((handle, name)); + self.fpathconf_result + } + fn host_mkdir(&mut self, path: &[u8], mode: u32) -> Result<(), Errno> { self.missing_paths.remove(path); self.file_modes @@ -11302,12 +14115,17 @@ mod tests { fn host_link(&mut self, _oldpath: &[u8], _newpath: &[u8]) -> Result<(), Errno> { Ok(()) } - fn host_symlink(&mut self, _target: &[u8], _linkpath: &[u8]) -> Result<(), Errno> { + fn host_symlink(&mut self, target: &[u8], linkpath: &[u8]) -> Result<(), Errno> { + self.set_symlink(linkpath, target); Ok(()) } - fn host_readlink(&mut self, _path: &[u8], buf: &mut [u8]) -> Result { - let target = b"/target"; + fn host_readlink(&mut self, path: &[u8], buf: &mut [u8]) -> Result { + let target = self + .symlink_targets + .get(path) + .map(Vec::as_slice) + .unwrap_or(b"/target"); let n = buf.len().min(target.len()); buf[..n].copy_from_slice(&target[..n]); Ok(n) @@ -11327,6 +14145,7 @@ mod tests { // Mirror the host VFS: chown updates owner state. A subsequent // host_stat(path) must return the new uid/gid. Tests rely on this // to verify sys_chown propagates through to the host VFS. + self.chown_calls.push((path.to_vec(), uid, gid)); self.file_owners.insert(path.to_vec(), (uid, gid)); for (handle, handle_path) in &self.handle_paths { if handle_path.as_slice() == path { @@ -11335,6 +14154,11 @@ mod tests { } Ok(()) } + fn host_lchown(&mut self, path: &[u8], uid: u32, gid: u32) -> Result<(), Errno> { + self.lchown_calls.push((path.to_vec(), uid, gid)); + self.file_owners.insert(path.to_vec(), (uid, gid)); + Ok(()) + } fn host_access(&mut self, _path: &[u8], _amode: u32) -> Result<(), Errno> { Ok(()) } @@ -11370,11 +14194,15 @@ mod tests { } } - fn host_closedir(&mut self, _handle: i64) -> Result<(), Errno> { + fn host_closedir(&mut self, handle: i64) -> Result<(), Errno> { + self.closed_dir_handles.push(handle); Ok(()) } fn host_clock_gettime(&mut self, _clock_id: u32) -> Result<(i64, i64), Errno> { + if let Some(err) = self.clock_error { + return Err(err); + } Ok(self.clock_time) } @@ -11382,7 +14210,8 @@ mod tests { Ok(()) } - fn host_ftruncate(&mut self, _handle: i64, _length: i64) -> Result<(), Errno> { + fn host_ftruncate(&mut self, _handle: i64, length: i64) -> Result<(), Errno> { + self.stat_size = u64::try_from(length).map_err(|_| Errno::EINVAL)?; Ok(()) } @@ -11401,6 +14230,7 @@ mod tests { // file_owners; without a reverse map we update the per-handle // overlay so host_fstat returns the new owner. Tests that also // care about host_stat(path) after fchown can use sys_chown. + self.fchown_calls.push((handle, uid, gid)); self.handle_owners.insert(handle, (uid, gid)); Ok(()) } @@ -11465,14 +14295,15 @@ mod tests { } fn host_net_connect( &mut self, - _handle: i32, - _addr: &[u8], - _port: u16, + handle: i32, + addr: &[u8], + port: u16, ) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) + self.net_connect_calls.push((handle, addr.to_vec(), port)); + self.net_connect_result } fn host_net_connect_status(&mut self, _handle: i32) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) + self.net_connect_status_result } fn host_net_send( &mut self, @@ -11480,7 +14311,7 @@ mod tests { _data: &[u8], _flags: u32, ) -> Result { - Err(Errno::ENOTCONN) + self.net_send_result } fn host_net_recv( &mut self, @@ -11494,7 +14325,8 @@ mod tests { fn host_net_close(&mut self, _handle: i32) -> Result<(), Errno> { Ok(()) } - fn host_net_listen(&mut self, _fd: i32, _port: u16, _addr: &[u8; 4]) -> Result<(), Errno> { + fn host_net_listen(&mut self, fd: i32, port: u16, addr: &[u8; 4]) -> Result<(), Errno> { + self.net_listen_calls.push((fd, port, *addr)); Ok(()) } fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { @@ -11867,6 +14699,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); @@ -11960,6 +14894,7 @@ mod tests { fn test_lstat_returns_symlink_info() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); + host.set_symlink(b"/tmp/link", b"/target"); let stat = sys_lstat(&mut proc, &mut host, b"/tmp/link").unwrap(); assert_eq!(stat.st_mode & S_IFLNK, S_IFLNK); } @@ -12042,6 +14977,185 @@ mod tests { assert_eq!(st.st_gid, 2000, "sys_chown gid did not reach host VFS"); } + #[test] + fn test_sys_chown_preserves_sentinels_and_delegates_same_ids() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_file_with_owner(b"/owned", 1000, 2000, 0o644, b"hi"); + + sys_chown( + &mut proc, + &mut host, + b"/owned", + CHOWN_ID_UNCHANGED, + 3000, + ) + .unwrap(); + sys_chown( + &mut proc, + &mut host, + b"/owned", + 4000, + CHOWN_ID_UNCHANGED, + ) + .unwrap(); + // An explicit request for the current IDs is not the (-1, -1) + // sentinel case: it must still reach the backend for ctime, set-ID, + // and filesystem error semantics. + sys_chown(&mut proc, &mut host, b"/owned", 4000, 3000).unwrap(); + sys_chown( + &mut proc, + &mut host, + b"/owned", + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ) + .unwrap(); + + assert_eq!( + host.chown_calls, + vec![ + (b"/owned".to_vec(), 1000, 3000), + (b"/owned".to_vec(), 4000, 3000), + (b"/owned".to_vec(), 4000, 3000), + (b"/owned".to_vec(), 4000, 3000), + ], + ); + } + + #[test] + fn test_sys_chown_both_sentinels_require_owner_and_validate_path() { + let mut proc = Process::new(1); + proc.euid = 1000; + let mut host = MockHostIO::new(); + host.set_file_with_owner(b"/owned", 1000, 2000, 0o644, b"hi"); + + sys_chown( + &mut proc, + &mut host, + b"/owned", + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ) + .unwrap(); + assert_eq!( + host.chown_calls, + vec![(b"/owned".to_vec(), 1000, 2000)], + ); + + proc.euid = 2000; + assert_eq!( + sys_chown( + &mut proc, + &mut host, + b"/owned", + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ), + Err(Errno::EPERM), + ); + assert_eq!( + sys_chown(&mut proc, &mut host, b"/owned", 1000, 2000), + Err(Errno::EPERM), + ); + assert_eq!( + sys_chown( + &mut proc, + &mut host, + b"/owned", + CHOWN_ID_UNCHANGED, + 2000, + ), + Err(Errno::EPERM), + ); + + host.set_missing_path(b"/missing"); + assert_eq!( + sys_chown( + &mut proc, + &mut host, + b"/missing", + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ), + Err(Errno::ENOENT), + ); + } + + #[test] + fn test_sys_lchown_changes_final_link_without_changing_target() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_file_with_owner(b"/target", 10, 20, 0o644, b"target"); + host.set_symlink(b"/link", b"/target"); + host.file_owners.insert(b"/link".to_vec(), (30, 40)); + + sys_lchown(&mut proc, &mut host, b"/link", 50, 60).unwrap(); + + let link = sys_lstat(&mut proc, &mut host, b"/link").unwrap(); + let target = sys_stat(&mut proc, &mut host, b"/link").unwrap(); + assert_eq!((link.st_uid, link.st_gid), (50, 60)); + assert_eq!((target.st_uid, target.st_gid), (10, 20)); + assert_eq!( + host.lchown_calls, + vec![(b"/link".to_vec(), 50, 60)], + ); + assert!(host.chown_calls.is_empty()); + } + + #[test] + fn test_sys_lchown_accepts_dangling_link_while_chown_follows() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_symlink(b"/dangling", b"/missing"); + host.file_owners.insert(b"/dangling".to_vec(), (70, 80)); + host.set_missing_path(b"/missing"); + + sys_lchown(&mut proc, &mut host, b"/dangling", 90, 100).unwrap(); + assert_eq!( + sys_chown(&mut proc, &mut host, b"/dangling", 1, 2), + Err(Errno::ENOENT), + ); + assert_eq!( + host.lchown_calls, + vec![(b"/dangling".to_vec(), 90, 100)], + ); + } + + #[test] + fn test_sys_lchown_sentinels_use_link_owner_and_still_delegate() { + let mut proc = Process::new(1); + proc.euid = 123; + let mut host = MockHostIO::new(); + host.set_symlink(b"/owned-link", b"/target"); + host.file_owners + .insert(b"/owned-link".to_vec(), (123, 456)); + host.set_file_with_owner(b"/target", 999, 888, 0o644, b"target"); + + sys_lchown( + &mut proc, + &mut host, + b"/owned-link", + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ) + .unwrap(); + assert_eq!( + host.lchown_calls, + vec![(b"/owned-link".to_vec(), 123, 456)], + ); + assert_eq!( + sys_lchown( + &mut proc, + &mut host, + b"/owned-link", + CHOWN_ID_UNCHANGED, + 777, + ), + Err(Errno::EPERM), + ); + } + /// sys_fchown must propagate uid/gid into the host VFS via the open file /// handle so that a subsequent sys_fstat returns the new values. #[test] @@ -12056,6 +15170,46 @@ mod tests { assert_eq!(st.st_gid, 4000, "sys_fchown gid did not reach host VFS"); } + #[test] + fn test_sys_fchown_preserves_each_sentinel_and_validates_fd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_file_with_owner(b"/fd-owned", 10, 20, 0o644, b"hi"); + let fd = sys_open(&mut proc, &mut host, b"/fd-owned", O_RDONLY, 0).unwrap(); + + sys_fchown(&mut proc, &mut host, fd, CHOWN_ID_UNCHANGED, 30).unwrap(); + sys_fchown(&mut proc, &mut host, fd, 40, CHOWN_ID_UNCHANGED).unwrap(); + proc.euid = 40; + sys_fchown( + &mut proc, + &mut host, + fd, + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ) + .unwrap(); + + let handle = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap() + .host_handle; + assert_eq!( + host.fchown_calls, + vec![(handle, 10, 30), (handle, 40, 30), (handle, 40, 30)], + ); + assert_eq!( + sys_fchown( + &mut proc, + &mut host, + 999, + CHOWN_ID_UNCHANGED, + CHOWN_ID_UNCHANGED, + ), + Err(Errno::EBADF), + ); + } + /// sys_fchownat with AT_FDCWD shares its propagation path with sys_chown /// (resolves path then calls host.host_chown). Round-trip via sys_stat to /// confirm the *at variant also reaches the host VFS — the syscall is @@ -12072,6 +15226,73 @@ mod tests { assert_eq!(st.st_gid, 6000, "sys_fchownat gid did not reach host VFS"); } + #[test] + fn test_sys_fchownat_preserves_each_sentinel() { + use wasm_posix_shared::flags::AT_FDCWD; + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_file_with_owner(b"/at-owned", 70, 80, 0o644, b"hi"); + + sys_fchownat( + &mut proc, + &mut host, + AT_FDCWD, + b"/at-owned", + CHOWN_ID_UNCHANGED, + 90, + 0, + ) + .unwrap(); + sys_fchownat( + &mut proc, + &mut host, + AT_FDCWD, + b"/at-owned", + 100, + CHOWN_ID_UNCHANGED, + 0, + ) + .unwrap(); + + assert_eq!( + host.chown_calls, + vec![ + (b"/at-owned".to_vec(), 70, 90), + (b"/at-owned".to_vec(), 100, 90), + ], + ); + } + + #[test] + fn test_sys_fchownat_selects_follow_or_nofollow_backend() { + use wasm_posix_shared::flags::{AT_FDCWD, AT_SYMLINK_NOFOLLOW}; + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_file_with_owner(b"/target", 1, 2, 0o644, b"target"); + host.set_symlink(b"/link", b"/target"); + host.file_owners.insert(b"/link".to_vec(), (3, 4)); + + sys_fchownat(&mut proc, &mut host, AT_FDCWD, b"/link", 5, 6, 0).unwrap(); + sys_fchownat( + &mut proc, + &mut host, + AT_FDCWD, + b"/link", + 7, + 8, + AT_SYMLINK_NOFOLLOW, + ) + .unwrap(); + + assert_eq!(host.chown_calls, vec![(b"/target".to_vec(), 5, 6)]); + assert_eq!(host.lchown_calls, vec![(b"/link".to_vec(), 7, 8)]); + assert_eq!( + sys_fchownat(&mut proc, &mut host, AT_FDCWD, b"/link", 9, 10, 0x200), + Err(Errno::EINVAL), + ); + } + #[test] fn test_mkdir_delegates_to_host() { let mut proc = Process::new(1); @@ -12090,6 +15311,7 @@ mod tests { fn test_readlink_returns_target() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); + host.set_symlink(b"/tmp/link", b"/target"); let mut buf = [0u8; 256]; let n = sys_readlink(&mut proc, &mut host, b"/tmp/link", &mut buf).unwrap(); assert_eq!(&buf[..n], b"/target"); @@ -12120,8 +15342,9 @@ mod tests { #[test] fn test_getcwd_returns_initial_cwd() { let proc = Process::new(1); + let mut host = MockHostIO::new(); let mut buf = [0u8; 256]; - let n = sys_getcwd(&proc, &mut buf).unwrap(); + let n = sys_getcwd(&proc, &mut host, &mut buf).unwrap(); // Linux convention: returned length includes the NUL terminator assert_eq!(&buf[..n], b"/\0"); } @@ -12132,7 +15355,7 @@ mod tests { let mut host = MockHostIO::new(); sys_chdir(&mut proc, &mut host, b"/tmp").unwrap(); let mut buf = [0u8; 256]; - let n = sys_getcwd(&proc, &mut buf).unwrap(); + let n = sys_getcwd(&proc, &mut host, &mut buf).unwrap(); assert_eq!(&buf[..n], b"/tmp\0"); } @@ -12145,36 +15368,216 @@ mod tests { // Then chdir to "subdir" relative (resolves to /tmp/subdir, ends with "dir") sys_chdir(&mut proc, &mut host, b"subdir").unwrap(); let mut buf = [0u8; 256]; - let n = sys_getcwd(&proc, &mut buf).unwrap(); + let n = sys_getcwd(&proc, &mut host, &mut buf).unwrap(); assert_eq!(&buf[..n], b"/tmp/subdir\0"); } #[test] - fn test_chdir_rejects_non_directory() { + fn pathname_resolution_does_not_erase_missing_or_nondirectory_components() { let mut proc = Process::new(1); + proc.cwd = b"/work".to_vec(); let mut host = MockHostIO::new(); - // Path "file.txt" doesn't end with dir/tmp, so MockHostIO returns S_IFREG - let result = sys_chdir(&mut proc, &mut host, b"/file.txt"); - assert_eq!(result, Err(Errno::ENOTDIR)); + host.set_dir_with_owner(b"/work", 0, 0, 0o755); + host.set_missing_path(b"/work/missing"); + host.set_file_with_owner(b"/regular", 0, 0, 0o644, b""); + + assert_eq!( + sys_stat(&mut proc, &mut host, b"missing/../file").unwrap_err(), + Errno::ENOENT, + ); + assert_eq!( + sys_stat(&mut proc, &mut host, b"/regular/.").unwrap_err(), + Errno::ENOTDIR, + ); } #[test] - fn test_getcwd_erange_when_buffer_too_small() { + fn chdir_stores_physical_path_after_symlink_and_dotdot() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - sys_chdir(&mut proc, &mut host, b"/tmp").unwrap(); - let mut buf = [0u8; 2]; // too small for "/tmp" - let result = sys_getcwd(&proc, &mut buf); - assert_eq!(result, Err(Errno::ERANGE)); + for path in [b"/a".as_slice(), b"/x".as_slice(), b"/x/y".as_slice()] { + host.set_dir_with_owner(path, 0, 0, 0o755); + } + host.set_symlink(b"/a/link", b"/x/y"); + + sys_chdir(&mut proc, &mut host, b"/a/link/..").unwrap(); + assert_eq!(proc.cwd, b"/x"); + + let stat = sys_stat(&mut proc, &mut host, b"/a/link/").unwrap(); + assert_eq!(stat.st_mode & S_IFMT, S_IFDIR); } #[test] - fn test_opendir_closedir_cycle() { + fn namespace_backends_never_receive_dotdot_components() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); - assert!(dh >= 0); - sys_closedir(&mut proc, &mut host, dh).unwrap(); + host.set_dir_with_owner(b"/tmp", 0, 0, 0o755); + host.set_dir_with_owner(b"/etc", 0, 0, 0o755); + host.set_file_with_owner(b"/etc/file", 0, 0, 0o644, b""); + + sys_stat(&mut proc, &mut host, b"/tmp/../etc/file").unwrap(); + assert!(host.lstat_paths.iter().all(|path| !path.windows(2).any(|w| w == b".."))); + assert!(host.lstat_paths.iter().any(|path| path == b"/etc/file")); + } + + #[test] + fn open_enforces_final_symlink_flags_before_host_delegation() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_symlink(b"/tmp/link", b"/missing-target"); + host.set_missing_path(b"/missing-target"); + + assert_eq!( + sys_open(&mut proc, &mut host, b"/tmp/link", O_RDONLY | O_NOFOLLOW, 0), + Err(Errno::ELOOP), + ); + assert_eq!( + sys_open( + &mut proc, + &mut host, + b"/tmp/link", + O_WRONLY | O_CREAT | O_EXCL, + 0o600, + ), + Err(Errno::EEXIST), + ); + } + + #[test] + fn access_uses_real_ids_for_component_search() { + let mut proc = Process::new(1); + proc.uid = 1000; + proc.gid = 1000; + proc.euid = 0; + proc.egid = 0; + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/secret", 0, 0, 0o700); + host.set_file_with_owner(b"/secret/file", 0, 0, 0o644, b""); + + assert_eq!( + sys_access(&mut proc, &mut host, b"/secret/file", R_OK), + Err(Errno::EACCES), + ); + assert!(sys_stat(&mut proc, &mut host, b"/secret/file").is_ok()); + + proc.uid = 0; + proc.gid = 0; + proc.euid = 1000; + proc.egid = 1000; + host.set_dir_with_owner(b"/root-only", 0, 0, 0o700); + host.set_file_with_owner(b"/root-only/file", 0, 0, 0o644, b""); + + assert!(sys_access(&mut proc, &mut host, b"/root-only/file", R_OK).is_ok()); + assert!( + sys_faccessat( + &mut proc, + &mut host, + AT_FDCWD, + b"/root-only/file", + R_OK, + 0, + ) + .is_ok() + ); + assert_eq!( + sys_faccessat( + &mut proc, + &mut host, + AT_FDCWD, + b"/root-only/file", + R_OK, + AT_EACCESS, + ), + Err(Errno::EACCES), + ); + } + + #[test] + fn rename_allows_missing_trailing_slash_only_for_directory_source() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/tmp/old-dir", 0, 0, 0o755); + host.set_file_with_owner(b"/tmp/old-file", 0, 0, 0o644, b""); + host.set_missing_path(b"/tmp/new-dir"); + host.set_missing_path(b"/tmp/new-file"); + + assert!(sys_rename(&mut proc, &mut host, b"/tmp/old-dir", b"/tmp/new-dir/").is_ok()); + assert_eq!( + sys_rename(&mut proc, &mut host, b"/tmp/old-file", b"/tmp/new-file/"), + Err(Errno::ENOENT), + ); + } + + #[test] + fn unix_socket_registry_uses_canonical_namespace_path() { + use wasm_posix_shared::socket::{AF_UNIX, SOCK_DGRAM}; + + let _guard = UNIX_REGISTRY_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/tmp/a", 0, 0, 0o755); + host.set_missing_path(b"/tmp/socket"); + + let aliased = test_unix_addr(b"/tmp/a/../socket"); + let canonical = test_unix_addr(b"/tmp/socket"); + let server = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_bind(&mut proc, &mut host, server, &aliased).unwrap(); + + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/socket")); + assert!(!unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/a/../socket")); + + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client, &canonical).unwrap(); + + host.set_missing_path(b"/tmp/renamed-socket"); + sys_rename( + &mut proc, + &mut host, + b"/tmp/socket", + b"/tmp/renamed-socket", + ) + .unwrap(); + host.set_missing_path(b"/tmp/socket"); + host.missing_paths.remove(b"/tmp/renamed-socket".as_slice()); + assert!(!unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/socket")); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .contains(b"/tmp/renamed-socket")); + + let renamed = test_unix_addr(b"/tmp/renamed-socket"); + let second_client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, second_client, &renamed).unwrap(); + sys_unlink(&mut proc, &mut host, b"/tmp/renamed-socket").unwrap(); + } + + #[test] + fn test_chdir_rejects_non_directory() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Path "file.txt" doesn't end with dir/tmp, so MockHostIO returns S_IFREG + let result = sys_chdir(&mut proc, &mut host, b"/file.txt"); + assert_eq!(result, Err(Errno::ENOTDIR)); + } + + #[test] + fn test_getcwd_erange_when_buffer_too_small() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_chdir(&mut proc, &mut host, b"/tmp").unwrap(); + let mut buf = [0u8; 2]; // too small for "/tmp" + let result = sys_getcwd(&proc, &mut host, &mut buf); + assert_eq!(result, Err(Errno::ERANGE)); + } + + #[test] + fn test_opendir_closedir_cycle() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let dh = sys_opendir(&mut proc, &mut host, b"/tmp").unwrap(); + assert!(dh >= 0); + sys_closedir(&mut proc, &mut host, dh).unwrap(); } #[test] @@ -12400,6 +15803,12 @@ mod tests { assert_eq!(proc.state, ProcessState::Exited); assert_eq!(proc.exit_status, 42); + assert_eq!(proc.exit_signal, 0); + let event = proc.wait_event.unwrap(); + assert_eq!(event.event_mask, wasm_posix_shared::wait::EVENT_EXITED); + assert_eq!(event.wait_status, 42 << 8); + assert_eq!(event.si_code, wasm_posix_shared::wait::CLD_EXITED); + assert_eq!(event.si_status, 42); // All fds should be closed - trying to read from fd should fail let mut buf = [0u8; 10]; @@ -12407,6 +15816,22 @@ mod tests { assert_eq!(result, Err(Errno::EBADF)); } + #[test] + fn test_exit_closes_fd_above_default_nofile_limit() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); + let low_fd = + sys_open(&mut proc, &mut host, b"/tmp/high-fd", O_RDWR | O_CREAT, 0o644).unwrap(); + let high_fd = sys_fcntl(&mut proc, low_fd, F_DUPFD, 2048).unwrap(); + sys_close(&mut proc, &mut host, low_fd).unwrap(); + + sys_exit(&mut proc, &mut host, 0); + + assert_eq!(high_fd, 2048); + assert_eq!(proc.fd_table.get(high_fd), Err(Errno::EBADF)); + } + #[test] fn test_exit_with_zero_status() { let mut proc = Process::new(1); @@ -12416,6 +15841,18 @@ mod tests { assert_eq!(proc.exit_status, 0); } + #[test] + fn test_exit_keeps_only_the_low_status_byte() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.exit_signal = 15; + + sys_exit(&mut proc, &mut host, 0x1ff); + + assert_eq!(proc.exit_status, 255); + assert_eq!(proc.exit_signal, 0); + } + #[test] fn test_kill_marks_signal_pending() { let mut proc = Process::new(1); @@ -12488,6 +15925,34 @@ mod tests { assert_eq!(old_h, 1); // was SIG_IGN } + #[test] + fn test_sigaction_ignore_accepts_pending_timer_notification() { + let mut proc = Process::new(1); + proc.add_thread(crate::process::ThreadInfo::new(2, 0, 0, 0)); + proc.posix_timers.push(Some(crate::process::PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value: 7, + sigev_notify: 4, + sigev_tid: 2, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 2, + overrun_last: 0, + })); + proc.get_thread_mut(2).unwrap().signals.raise_timer(10, 7, 0); + + sys_sigaction(&mut proc, 10, SIG_IGN, 0, 0).unwrap(); + + assert!(!proc.get_thread(2).unwrap().signals.is_pending(10)); + let timer = proc.posix_timers[0].as_ref().unwrap(); + assert!(!timer.notification_pending); + assert_eq!(timer.overrun_last, 2); + } + #[test] fn test_sigaction_with_flags_and_mask() { use wasm_posix_shared::signal::SA_RESTART; @@ -12736,4190 +16201,7004 @@ mod tests { ofd.host_handle } - #[test] - fn test_fcntl_setlk_fallback_conflict_reports_would_block() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); - - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 100, - }, - ); - - let mut flock = WasmFlock { - l_type: F_WRLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 0, - l_len: 100, - l_pid: 0, - _pad2: 0, - }; - let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLK, &mut flock, &mut host); - assert_eq!(result, Err(Errno::EAGAIN)); - } - - #[test] - fn test_fcntl_setlkw_fallback_conflict_uses_cooperative_retry() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); - - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 100, - }, - ); - - let mut flock = WasmFlock { - l_type: F_WRLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 0, - l_len: 100, - l_pid: 0, - _pad2: 0, - }; - let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host); - assert_eq!(result, Err(Errno::EAGAIN)); - } - - struct FallbackLockTestGuard { - _guard: std::sync::MutexGuard<'static, ()>, + fn descriptor_backing_idx(proc: &Process, fd: i32) -> usize { + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + if ofd.file_type == FileType::Regular + && crate::procfs::is_procfs_buf_handle(ofd.host_handle) + { + crate::procfs::procfs_buf_idx(ofd.host_handle) + } else { + (-(ofd.host_handle + 1)) as usize + } } - impl Drop for FallbackLockTestGuard { - fn drop(&mut self) { - unsafe { crate::lock::global_fallback_lock_table().clear() }; + fn descriptor_backing_ref_count(file_type: FileType, idx: usize) -> Option { + match file_type { + FileType::EventFd => { + crate::descriptor_backing::with_eventfds(|table| table.ref_count(idx)) + } + FileType::TimerFd => { + crate::descriptor_backing::with_timerfds(|table| table.ref_count(idx)) + } + FileType::SignalFd => { + crate::descriptor_backing::with_signalfds(|table| table.ref_count(idx)) + } + FileType::MemFd => crate::descriptor_backing::with_memfds(|table| table.ref_count(idx)), + FileType::Regular => { + crate::descriptor_backing::with_procfs_bufs(|table| table.ref_count(idx)) + } + _ => None, } } - fn enter_fallback_lock_test() -> FallbackLockTestGuard { - let guard = crate::lock::FALLBACK_LOCK_TEST_LOCK.lock().unwrap(); - unsafe { crate::lock::global_fallback_lock_table().clear() }; - FallbackLockTestGuard { _guard: guard } + fn descriptor_backing_generation(file_type: FileType, idx: usize) -> Option { + match file_type { + FileType::EventFd => { + crate::descriptor_backing::with_eventfds(|table| table.generation(idx)) + } + FileType::TimerFd => { + crate::descriptor_backing::with_timerfds(|table| table.generation(idx)) + } + FileType::SignalFd => { + crate::descriptor_backing::with_signalfds(|table| table.generation(idx)) + } + FileType::MemFd => { + crate::descriptor_backing::with_memfds(|table| table.generation(idx)) + } + FileType::Regular => { + crate::descriptor_backing::with_procfs_bufs(|table| table.generation(idx)) + } + _ => None, + } } - fn add_fallback_pipe_fd(proc: &mut Process, host_handle: i64, status_flags: u32) -> i32 { - let ofd_idx = proc.ofd_table.create( - FileType::Pipe, - status_flags, - host_handle, - b"/dev/pipe".to_vec(), + fn assert_descriptor_backing_released(file_type: FileType, idx: usize, generation: u64) { + assert_ne!( + descriptor_backing_generation(file_type, idx), + Some(generation), + "the original backing identity must no longer be live" ); - proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() - } - - fn write_lock(start: i64, len: i64) -> WasmFlock { - WasmFlock { - l_type: F_WRLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: start, - l_len: len, - l_pid: 0, - _pad2: 0, - } } #[test] - fn test_fcntl_getlk_fallback_conflict_reports_blocking_owner() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); + fn shared_eventfd_backing_survives_fork_and_spawn_without_aliasing() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 970_100; + const FORK_CHILD: u32 = 970_101; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); + table.create_process(PARENT).unwrap(); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 7, - lock_type: F_WRLCK, - start: 10, - len: 25, - }, + let inherited_fd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 0, O_NONBLOCK).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(3) ); - let mut query = WasmFlock { - l_type: F_RDLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 20, - l_len: 5, - l_pid: 0, - _pad2: 0, - }; - sys_fcntl_lock(&mut proc, write_fd, F_GETLK, &mut query, &mut host).unwrap(); + let fresh_fd = sys_eventfd2(table.get_mut(FORK_CHILD).unwrap(), 33, O_NONBLOCK).unwrap(); + assert_ne!( + fd_host_handle(table.get(FORK_CHILD).unwrap(), inherited_fd), + fd_host_handle(table.get(FORK_CHILD).unwrap(), fresh_fd), + "a child-created eventfd must not reuse an inherited live backing" + ); - assert_eq!(query.l_type as u32, F_WRLCK); - assert_eq!(query.l_pid, 7); - assert_eq!(query.l_start, 10); - assert_eq!(query.l_len, 25); - assert_eq!(query.l_whence, 0); + sys_write( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &9u64.to_le_bytes(), + ) + .unwrap(); + let mut value = [0u8; 8]; + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut value, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(value), 9); + assert_eq!( + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut value, + ), + Err(Errno::EAGAIN), + "consuming through one descendant must drain the shared counter" + ); + + sys_close(table.get_mut(PARENT).unwrap(), &mut host, inherited_fd).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(2) + ); + table.remove_process(FORK_CHILD).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + + sys_write( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &4u64.to_le_bytes(), + ) + .unwrap(); + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut value, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(value), 4); + sys_close(table.get_mut(spawn_child).unwrap(), &mut host, inherited_fd).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); + table.remove_process(spawn_child).unwrap(); + table.remove_process(PARENT).unwrap(); } #[test] - fn test_fallback_locks_are_kernel_wide() { - let _guard = enter_fallback_lock_test(); - let mut proc1 = Process::new(1); - let mut proc2 = Process::new(2); - let mut host = MockHostIO::new(); - let shared_handle = -700; - let fd1 = add_fallback_pipe_fd(&mut proc1, shared_handle, O_RDWR); - let fd2 = add_fallback_pipe_fd(&mut proc2, shared_handle, O_RDWR); + fn shared_timerfd_backing_survives_fork_and_spawn_without_aliasing() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; - let mut flock = write_lock(0, 100); - sys_fcntl_lock(&mut proc1, fd1, F_SETLK, &mut flock, &mut host).unwrap(); + const PARENT: u32 = 970_200; + const FORK_CHILD: u32 = 970_201; + let mut table = ProcessTable::new(); + let mut host = MockHostIO::new(); + host.clock_time = (100, 0); + table.create_process(PARENT).unwrap(); + let inherited_fd = + sys_timerfd_create(table.get_mut(PARENT).unwrap(), 0, O_NONBLOCK).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::TimerFd, backing_idx).unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); - assert!( - proc1 - .lock_table - .get_blocking_lock(shared_handle, F_WRLCK, 0, 100, 2) - .is_none(), - "kernel fallback locks must not be stored on proc1" + sys_timerfd_settime( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + 0, + 0, + 0, + 5, + 0, + ) + .unwrap(); + assert_eq!( + sys_timerfd_gettime(table.get_mut(spawn_child).unwrap(), &mut host, inherited_fd,) + .unwrap(), + (0, 0, 5, 0) ); - assert!( - proc2 - .lock_table - .get_blocking_lock(shared_handle, F_WRLCK, 0, 100, 1) - .is_none(), - "kernel fallback locks must not be stored on proc2" + let fresh_fd = + sys_timerfd_create(table.get_mut(spawn_child).unwrap(), 0, O_NONBLOCK).unwrap(); + assert_ne!( + fd_host_handle(table.get(spawn_child).unwrap(), inherited_fd), + fd_host_handle(table.get(spawn_child).unwrap(), fresh_fd) ); - let mut conflict = write_lock(0, 100); + host.clock_time = (106, 0); + let mut count = [0u8; 8]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut count, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(count), 1); assert_eq!( - sys_fcntl_lock(&mut proc2, fd2, F_SETLK, &mut conflict, &mut host), + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut count, + ), Err(Errno::EAGAIN) ); - let mut query = WasmFlock { - l_type: F_RDLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 50, - l_len: 10, - l_pid: 0, - _pad2: 0, - }; - sys_fcntl_lock(&mut proc2, fd2, F_GETLK, &mut query, &mut host).unwrap(); - assert_eq!(query.l_type as u32, F_WRLCK); - assert_eq!(query.l_pid, 1); - assert_eq!(query.l_start, 0); - assert_eq!(query.l_len, 100); - - let mut unlock = WasmFlock { - l_type: F_UNLCK as i16, - l_whence: 0, - _pad1: 0, - l_start: 0, - l_len: 100, - l_pid: 0, - _pad2: 0, - }; - sys_fcntl_lock(&mut proc1, fd1, F_SETLK, &mut unlock, &mut host).unwrap(); - sys_fcntl_lock(&mut proc2, fd2, F_SETLK, &mut conflict, &mut host).unwrap(); - - assert!( - unsafe { crate::lock::global_fallback_lock_table() } - .get_blocking_lock(shared_handle, F_WRLCK, 0, 100, 1) - .is_some() - ); + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::TimerFd, backing_idx, backing_generation); } #[test] - fn test_flock_fallback_nonblocking_conflict_reports_would_block() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); + fn shared_signalfd_mask_keeps_pending_queues_process_local() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + use wasm_posix_shared::signal::{SIGINT, SIGTERM, SIGUSR1}; + + const PARENT: u32 = 970_300; + const FORK_CHILD: u32 = 970_301; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let host_handle = fd_host_handle(&proc, write_fd); + table.create_process(PARENT).unwrap(); + let inherited_fd = sys_signalfd4( + table.get_mut(PARENT).unwrap(), + -1, + 1u64 << (SIGINT - 1), + O_NONBLOCK, + ) + .unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::SignalFd, backing_idx).unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 0, - }, - ); + let usr1_mask = 1u64 << (SIGUSR1 - 1); + sys_signalfd4( + table.get_mut(FORK_CHILD).unwrap(), + inherited_fd, + usr1_mask, + 0, + ) + .unwrap(); + table.get_mut(PARENT).unwrap().signals.raise(SIGUSR1); + table.get_mut(spawn_child).unwrap().signals.raise(SIGUSR1); + let mut info = [0u8; 128]; assert_eq!( - sys_flock(&mut proc, write_fd, LOCK_EX | LOCK_NB, &mut host), - Err(Errno::EAGAIN) + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut info, + ), + Err(Errno::EAGAIN), + "the shared mask must not merge per-process pending queues" ); - } - - #[test] - fn test_flock_blocking_fallback_conflict_uses_cooperative_retry() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let host_handle = -701; - let write_fd = add_fallback_pipe_fd(&mut proc, host_handle, O_RDWR); + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut info, + ) + .unwrap(); + assert_eq!(u32::from_le_bytes(info[..4].try_into().unwrap()), SIGUSR1); + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut info, + ) + .unwrap(); + assert_eq!(u32::from_le_bytes(info[..4].try_into().unwrap()), SIGUSR1); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 0, - }, + let fresh_fd = sys_signalfd4( + table.get_mut(FORK_CHILD).unwrap(), + -1, + 1u64 << (SIGTERM - 1), + O_NONBLOCK, + ) + .unwrap(); + assert_ne!( + fd_host_handle(table.get(FORK_CHILD).unwrap(), inherited_fd), + fd_host_handle(table.get(FORK_CHILD).unwrap(), fresh_fd) ); - let result = sys_flock(&mut proc, write_fd, LOCK_EX, &mut host); - assert_eq!(result, Err(Errno::EAGAIN)); + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::SignalFd, backing_idx, backing_generation); } #[test] - fn test_fcntl_setlkw_fallback_succeeds_after_unlock() { - let _guard = enter_fallback_lock_test(); - let mut proc = Process::new(1); + fn shared_memfd_data_and_cursor_survive_fork_and_spawn() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; + + const PARENT: u32 = 970_400; + const FORK_CHILD: u32 = 970_401; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let host_handle = -702; - let write_fd = add_fallback_pipe_fd(&mut proc, host_handle, O_RDWR); + table.create_process(PARENT).unwrap(); + let inherited_fd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"shared", 0).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::MemFd, backing_idx).unwrap(); + sys_write( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + b"abcdef", + ) + .unwrap(); + sys_lseek( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + 0, + SEEK_SET, + ) + .unwrap(); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_WRLCK, - start: 0, - len: 100, - }, - ); + let mut pair = [0u8; 2]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut pair, + ) + .unwrap(); + assert_eq!(&pair, b"ab"); + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut pair, + ) + .unwrap(); + assert_eq!(&pair, b"cd"); + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut pair, + ) + .unwrap(); + assert_eq!(&pair, b"ef"); - let mut flock = write_lock(0, 100); - let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host); - assert_eq!(result, Err(Errno::EAGAIN)); + sys_pwrite( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + b"ZZ", + 1, + ) + .unwrap(); + let mut all = [0u8; 6]; + sys_pread( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut all, + 0, + ) + .unwrap(); + assert_eq!(&all, b"aZZdef"); + assert_eq!( + sys_lseek( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + 0, + SEEK_CUR, + ) + .unwrap(), + 6, + "positioned I/O must not move the shared cursor" + ); - unsafe { crate::lock::global_fallback_lock_table() }.set_lock( - host_handle, - FileLock { - pid: 2, - lock_type: F_UNLCK, - start: 0, - len: 100, - }, + sys_ftruncate( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + 4, + ) + .unwrap(); + assert_eq!( + sys_fstat(table.get_mut(PARENT).unwrap(), &mut host, inherited_fd) + .unwrap() + .st_size, + 4 ); - sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host).unwrap(); - assert!( - unsafe { crate::lock::global_fallback_lock_table() } - .get_blocking_lock(host_handle, F_WRLCK, 0, 100, 2) - .is_some() + let fresh_fd = sys_memfd_create(table.get_mut(FORK_CHILD).unwrap(), b"fresh", 0).unwrap(); + assert_ne!( + fd_host_handle(table.get(FORK_CHILD).unwrap(), inherited_fd), + fd_host_handle(table.get(FORK_CHILD).unwrap(), fresh_fd) ); + sys_write( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + fresh_fd, + b"independent", + ) + .unwrap(); + let mut truncated = [0u8; 4]; + sys_pread( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut truncated, + 0, + ) + .unwrap(); + assert_eq!(&truncated, b"aZZd"); + + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::MemFd, backing_idx, backing_generation); } #[test] - fn test_close_releases_fcntl_locks() { - let mut proc = Process::new(1); + fn inherited_memfd_seek_cur_lock_and_fdinfo_use_peer_advanced_cursor() { + use crate::process_table::ProcessTable; + + let _locks = enter_fallback_lock_test(); + const PARENT: u32 = 970_450; + const CHILD: u32 = 970_451; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/lockfile", - O_RDWR | O_CREAT, - 0o644, - ) - .unwrap(); + table.create_process(PARENT).unwrap(); + let fd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"cursor-lock", 0).unwrap(); + sys_write(table.get_mut(PARENT).unwrap(), &mut host, fd, b"abcdefgh").unwrap(); + sys_lseek(table.get_mut(PARENT).unwrap(), &mut host, fd, 0, SEEK_SET).unwrap(); + table.fork_process(PARENT, CHILD).unwrap(); - // Acquire a write lock (delegates to host for host-backed files) - let mut flock = WasmFlock { + let mut prefix = [0u8; 3]; + sys_read(table.get_mut(CHILD).unwrap(), &mut host, fd, &mut prefix).unwrap(); + assert_eq!(&prefix, b"abc"); + let fdinfo = crate::procfs::generate_fdinfo(table.get(PARENT).unwrap(), fd).unwrap(); + assert!( + core::str::from_utf8(&fdinfo).unwrap().contains("pos:\t3\n"), + "fdinfo must report the peer-advanced shared cursor" + ); + + let mut parent_lock = WasmFlock { l_type: F_WRLCK as i16, - l_whence: 0, + l_whence: SEEK_CUR as i16, _pad1: 0, - l_start: 0, - l_len: 100, + l_start: 2, + l_len: 1, l_pid: 0, _pad2: 0, }; - sys_fcntl_lock(&mut proc, fd, F_SETLK, &mut flock, &mut host).unwrap(); - - // Close the fd — should release all locks on this file (delegates unlock to host) - sys_close(&mut proc, &mut host, fd).unwrap(); - - // Verify the fd was actually closed - assert!(proc.fd_table.get(fd).is_err()); - } - - #[test] - fn test_exit_releases_fcntl_locks() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, + sys_fcntl_lock( + table.get_mut(PARENT).unwrap(), + fd, + F_SETLK, + &mut parent_lock, &mut host, - b"/tmp/lockfile", - O_RDWR | O_CREAT, - 0o644, ) .unwrap(); - // Acquire a write lock (delegates to host for host-backed files) - let mut flock = WasmFlock { + let mut query = WasmFlock { l_type: F_WRLCK as i16, - l_whence: 0, + l_whence: SEEK_SET as i16, _pad1: 0, - l_start: 0, - l_len: 100, + l_start: 5, + l_len: 1, l_pid: 0, _pad2: 0, }; - sys_fcntl_lock(&mut proc, fd, F_SETLK, &mut flock, &mut host).unwrap(); - - // Exit the process — should release all locks (both local and via host) - sys_exit(&mut proc, &mut host, 0); + sys_fcntl_lock( + table.get_mut(CHILD).unwrap(), + fd, + F_GETLK, + &mut query, + &mut host, + ) + .unwrap(); + assert_eq!(query.l_type as u32, F_WRLCK); + assert_eq!(query.l_start, 5); + assert_eq!(query.l_pid, PARENT); - // Process should be in Exited state - assert_eq!(proc.state, ProcessState::Exited); - // Local lock table should also be cleaned - assert!( - proc.lock_table - .get_blocking_lock(0, F_WRLCK as u32, 0, 100, 999) - .is_none() - ); + table.remove_process(CHILD).unwrap(); + table.remove_process(PARENT).unwrap(); } #[test] - fn test_clock_gettime() { - let proc = Process::new(1); - let mut host = MockHostIO::new(); - let ts = sys_clock_gettime(&proc, &mut host, 0).unwrap(); - assert_eq!(ts.tv_sec, 1234567890); - assert_eq!(ts.tv_nsec, 123456789); - } + fn shared_procfs_snapshot_and_cursor_survive_fork_and_spawn() { + use crate::process_table::ProcessTable; + use crate::spawn::SpawnAttrs; - #[test] - fn test_nanosleep_rejects_negative() { - let proc = Process::new(1); + const PARENT: u32 = 970_500; + const FORK_CHILD: u32 = 970_501; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let req = WasmTimespec { - tv_sec: -1, - tv_nsec: 0, - }; - assert_eq!(sys_nanosleep(&proc, &mut host, &req), Err(Errno::EINVAL)); - } + table.create_process(PARENT).unwrap(); + table.get_mut(PARENT).unwrap().argv = vec![b"parent-program".to_vec()]; + let expected = crate::procfs::generate_stat(table.get(PARENT).unwrap()); + let inherited_fd = crate::procfs::procfs_open( + table.get_mut(PARENT).unwrap(), + &crate::procfs::ProcfsEntry::Stat(PARENT), + b"/proc/self/stat".to_vec(), + 0, + ) + .unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), inherited_fd); + let backing_generation = + descriptor_backing_generation(FileType::Regular, backing_idx).unwrap(); - #[test] - fn test_nanosleep_rejects_invalid_nsec() { - let proc = Process::new(1); - let mut host = MockHostIO::new(); - let req = WasmTimespec { - tv_sec: 0, - tv_nsec: 1_000_000_000, - }; - assert_eq!(sys_nanosleep(&proc, &mut host, &req), Err(Errno::EINVAL)); + let mut first = [0u8; 7]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut first, + ) + .unwrap(); + assert_eq!(&first, &expected[..7]); + table.fork_process(PARENT, FORK_CHILD).unwrap(); + let spawn_child = table + .spawn_child( + PARENT, + &[b"spawn-program"], + &[], + &[], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap(); + + let mut second = [0u8; 9]; + sys_read( + table.get_mut(FORK_CHILD).unwrap(), + &mut host, + inherited_fd, + &mut second, + ) + .unwrap(); + assert_eq!(&second, &expected[7..16]); + let fdinfo = + crate::procfs::generate_fdinfo(table.get(PARENT).unwrap(), inherited_fd).unwrap(); + assert!( + core::str::from_utf8(&fdinfo) + .unwrap() + .contains("pos:\t16\n") + ); + let mut third = [0u8; 11]; + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + inherited_fd, + &mut third, + ) + .unwrap(); + assert_eq!(&third, &expected[16..27]); + + let fresh_expected = crate::procfs::generate_stat(table.get(spawn_child).unwrap()); + let fresh_fd = crate::procfs::procfs_open( + table.get_mut(spawn_child).unwrap(), + &crate::procfs::ProcfsEntry::Stat(spawn_child), + b"/proc/self/stat".to_vec(), + 0, + ) + .unwrap(); + assert_ne!( + fd_host_handle(table.get(spawn_child).unwrap(), inherited_fd), + fd_host_handle(table.get(spawn_child).unwrap(), fresh_fd) + ); + let mut fresh_prefix = [0u8; 12]; + sys_read( + table.get_mut(spawn_child).unwrap(), + &mut host, + fresh_fd, + &mut fresh_prefix, + ) + .unwrap(); + assert_eq!(&fresh_prefix, &fresh_expected[..12]); + + let mut fourth = [0u8; 5]; + sys_read( + table.get_mut(PARENT).unwrap(), + &mut host, + inherited_fd, + &mut fourth, + ) + .unwrap(); + assert_eq!(&fourth, &expected[27..32]); + + table.remove_process(PARENT).unwrap(); + table.remove_process(FORK_CHILD).unwrap(); + table.remove_process(spawn_child).unwrap(); + assert_descriptor_backing_released(FileType::Regular, backing_idx, backing_generation); } #[test] - fn test_nanosleep_valid() { - let proc = Process::new(1); + fn fork_clofork_filter_recomputes_ofd_refs_and_backing_lifetime() { + use crate::process_table::ProcessTable; + + const PARENT: u32 = 970_600; + const CHILD: u32 = 970_601; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let req = WasmTimespec { - tv_sec: 1, - tv_nsec: 500_000_000, - }; - assert_eq!(sys_nanosleep(&proc, &mut host, &req), Ok(())); - } + table.create_process(PARENT).unwrap(); + let clo_fork_fd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 1, 0).unwrap(); + let inherited_alias = sys_dup(table.get_mut(PARENT).unwrap(), clo_fork_fd).unwrap(); + let backing_idx = descriptor_backing_idx(table.get(PARENT).unwrap(), clo_fork_fd); + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + table + .get_mut(PARENT) + .unwrap() + .fd_table + .get_mut(clo_fork_fd) + .unwrap() + .fd_flags |= FD_CLOFORK; - #[test] - fn test_isatty_stdin() { - let proc = terminal_process(1); - assert_eq!(sys_isatty(&proc, 0), Ok(1)); + table.fork_process(PARENT, CHILD).unwrap(); + let child = table.get(CHILD).unwrap(); + assert!(child.fd_table.get(clo_fork_fd).is_err()); + let child_entry = child.fd_table.get(inherited_alias).unwrap(); + assert_eq!( + child + .ofd_table + .get(child_entry.ofd_ref.0) + .unwrap() + .ref_count, + 1, + "fork must recompute local OFD refs after filtering CLOFORK aliases" + ); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(2) + ); + + sys_close(table.get_mut(CHILD).unwrap(), &mut host, inherited_alias).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + sys_close(table.get_mut(PARENT).unwrap(), &mut host, clo_fork_fd).unwrap(); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1), + "closing one local alias must not drop the process's OFD reference" + ); + sys_close(table.get_mut(PARENT).unwrap(), &mut host, inherited_alias).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); + table.remove_process(CHILD).unwrap(); + table.remove_process(PARENT).unwrap(); } #[test] - fn test_isatty_regular_file() { - let mut proc = Process::new(1); + fn spawn_cloexec_and_action_rollback_balance_all_shared_backings() { + use crate::process_table::ProcessTable; + use crate::spawn::{FileAction, SpawnAttrs}; + use wasm_posix_shared::signal::SIGINT; + + const PARENT: u32 = 970_700; + let mut table = ProcessTable::new(); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY, 0o644).unwrap(); - assert_eq!(sys_isatty(&proc, fd), Err(Errno::ENOTTY)); - } + table.create_process(PARENT).unwrap(); - #[test] - fn test_isatty_invalid_fd() { - let proc = Process::new(1); - assert_eq!(sys_isatty(&proc, 99), Err(Errno::EBADF)); - } + let eventfd = sys_eventfd2(table.get_mut(PARENT).unwrap(), 0, O_CLOEXEC).unwrap(); + let timerfd = sys_timerfd_create(table.get_mut(PARENT).unwrap(), 0, O_CLOEXEC).unwrap(); + let signalfd = sys_signalfd4( + table.get_mut(PARENT).unwrap(), + -1, + 1u64 << (SIGINT - 1), + O_CLOEXEC, + ) + .unwrap(); + let memfd = sys_memfd_create(table.get_mut(PARENT).unwrap(), b"cloexec", 1).unwrap(); + let procfd = crate::procfs::procfs_open( + table.get_mut(PARENT).unwrap(), + &crate::procfs::ProcfsEntry::Stat(PARENT), + b"/proc/self/stat".to_vec(), + O_CLOEXEC, + ) + .unwrap(); + let tracked = [ + (eventfd, FileType::EventFd), + (timerfd, FileType::TimerFd), + (signalfd, FileType::SignalFd), + (memfd, FileType::MemFd), + (procfd, FileType::Regular), + ] + .map(|(fd, file_type)| { + let idx = descriptor_backing_idx(table.get(PARENT).unwrap(), fd); + ( + fd, + file_type, + idx, + descriptor_backing_generation(file_type, idx).unwrap(), + ) + }); - #[test] - fn test_setenv_getenv() { - let mut proc = Process::new(1); - sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); - let mut buf = [0u8; 256]; - let n = sys_getenv(&proc, b"HOME", &mut buf).unwrap(); - assert_eq!(&buf[..n], b"/home/user"); - } + let child = table + .spawn_child(PARENT, &[], &[], &[], &SpawnAttrs::empty(), &mut host) + .unwrap(); + for (fd, file_type, idx, _) in tracked { + assert!(table.get(child).unwrap().fd_table.get(fd).is_err()); + assert_eq!(descriptor_backing_ref_count(file_type, idx), Some(1)); + } + + let err = table + .spawn_child( + PARENT, + &[], + &[], + &[FileAction::Dup2 { srcfd: 999, fd: 1 }], + &SpawnAttrs::empty(), + &mut host, + ) + .unwrap_err(); + assert_eq!(err, Errno::EBADF); + for (_, file_type, idx, _) in tracked { + assert_eq!( + descriptor_backing_ref_count(file_type, idx), + Some(1), + "failed spawn must roll back every inherited backing ref" + ); + } - #[test] - fn test_setenv_no_overwrite() { - let mut proc = Process::new(1); - sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); - sys_setenv(&mut proc, b"HOME", b"/other", false).unwrap(); - let mut buf = [0u8; 256]; - let n = sys_getenv(&proc, b"HOME", &mut buf).unwrap(); - assert_eq!(&buf[..n], b"/home/user"); + table.remove_process(child).unwrap(); + for (fd, _, _, _) in tracked { + sys_close(table.get_mut(PARENT).unwrap(), &mut host, fd).unwrap(); + } + for (_, file_type, idx, generation) in tracked { + assert_descriptor_backing_released(file_type, idx, generation); + } + table.remove_process(PARENT).unwrap(); } #[test] - fn test_setenv_overwrite() { - let mut proc = Process::new(1); - sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); - sys_setenv(&mut proc, b"HOME", b"/other", true).unwrap(); - let mut buf = [0u8; 256]; - let n = sys_getenv(&proc, b"HOME", &mut buf).unwrap(); - assert_eq!(&buf[..n], b"/other"); - } + fn legacy_exec_transfers_survivor_and_releases_cloexec_only_backing() { + use crate::process_table::ProcessTable; - #[test] - fn test_unsetenv() { - let mut proc = Process::new(1); - sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); - sys_unsetenv(&mut proc, b"HOME").unwrap(); - let mut buf = [0u8; 256]; - assert_eq!(sys_getenv(&proc, b"HOME", &mut buf), Err(Errno::ENOENT)); + const PID: u32 = 970_800; + let mut old = Process::new(PID); + let mut host = MockHostIO::new(); + let removed_fd = sys_eventfd2(&mut old, 11, O_CLOEXEC).unwrap(); + let retained_fd = sys_eventfd2(&mut old, 22, 0).unwrap(); + let filtered_alias = sys_dup(&mut old, retained_fd).unwrap(); + old.fd_table.get_mut(filtered_alias).unwrap().fd_flags |= FD_CLOEXEC; + let removed_idx = descriptor_backing_idx(&old, removed_fd); + let removed_generation = + descriptor_backing_generation(FileType::EventFd, removed_idx).unwrap(); + let retained_idx = descriptor_backing_idx(&old, retained_fd); + + let serialized = crate::fork::serialize_exec_state_with_growing_buffer(&old).unwrap(); + let replacement = crate::fork::deserialize_exec_state(&serialized, PID).unwrap(); + assert!(replacement.fd_table.get(removed_fd).is_err()); + assert!(replacement.fd_table.get(filtered_alias).is_err()); + let retained_ofd_idx = replacement.fd_table.get(retained_fd).unwrap().ofd_ref.0; + assert_eq!( + replacement + .ofd_table + .get(retained_ofd_idx) + .unwrap() + .ref_count, + 1 + ); + + let mut table = ProcessTable::new(); + table.processes.insert(PID, old); + table.replace_legacy_exec_process(PID, replacement).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, removed_idx, removed_generation); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, retained_idx), + Some(1), + "the replacement must transfer, not duplicate, the survivor's ownership ref" + ); + + let mut value = [0u8; 8]; + sys_read( + table.get_mut(PID).unwrap(), + &mut host, + retained_fd, + &mut value, + ) + .unwrap(); + assert_eq!(u64::from_le_bytes(value), 22); + sys_close(table.get_mut(PID).unwrap(), &mut host, retained_fd).unwrap(); + table.remove_process(PID).unwrap(); } #[test] - fn test_getenv_not_found() { - let proc = Process::new(1); - let mut buf = [0u8; 256]; - assert_eq!(sys_getenv(&proc, b"NONEXIST", &mut buf), Err(Errno::ENOENT)); + fn legacy_fork_into_fresh_table_keeps_host_handle_single_owned() { + use crate::process_table::ProcessTable; + + const PARENT: u32 = 970_810; + const CHILD: u32 = 970_811; + const HOST_HANDLE: i64 = 9_708_110; + + let mut source = Process::new(PARENT); + let ofd_idx = source.ofd_table.create( + FileType::Regular, + O_RDWR, + HOST_HANDLE, + b"/fresh-kernel-handle".to_vec(), + ); + let fd = source + .fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .unwrap(); + let mut serialized = vec![0u8; 64 * 1024]; + let written = crate::fork::serialize_fork_state(&source, &mut serialized).unwrap(); + let child = crate::fork::deserialize_fork_state(&serialized[..written], CHILD).unwrap(); + assert_eq!(child.ppid, PARENT); + + let mut table = ProcessTable::new(); + table.insert_legacy_fork_process(child).unwrap(); + let mut host = MockHostIO::new(); + sys_close(table.get_mut(CHILD).unwrap(), &mut host, fd).unwrap(); + assert_eq!(host.closed_handles, vec![HOST_HANDLE]); } #[test] - fn test_setenv_rejects_empty_name() { - let mut proc = Process::new(1); + fn legacy_fork_into_fresh_table_rejects_reused_special_backing() { + use crate::process_table::ProcessTable; + + const OWNER: u32 = 970_820; + const CHILD: u32 = 970_821; + let mut owner = Process::new(OWNER); + let owner_fd = sys_eventfd2(&mut owner, 37, 0).unwrap(); + let backing_idx = descriptor_backing_idx(&owner, owner_fd); + let generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + + // Model a stale serialized child whose stable index now names another + // process's live object. A fresh-table legacy install has no parent + // ownership to transfer and must reject rather than add a reference. + let mut child = Process::new(CHILD); + child.ppid = 999_999; + let stale_handle = -((backing_idx as i64) + 1); + let stale_ofd = child.ofd_table.create( + FileType::EventFd, + O_RDWR, + stale_handle, + b"/dev/eventfd".to_vec(), + ); + child + .fd_table + .alloc(OpenFileDescRef(stale_ofd), 0) + .unwrap(); + + let mut table = ProcessTable::new(); + assert_eq!(table.insert_legacy_fork_process(child), Err(Errno::EBADF)); + assert!(table.get(CHILD).is_none()); assert_eq!( - sys_setenv(&mut proc, b"", b"value", true), - Err(Errno::EINVAL) + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + assert_eq!( + descriptor_backing_generation(FileType::EventFd, backing_idx), + Some(generation) ); + + let mut host = MockHostIO::new(); + let mut value = [0u8; 8]; + sys_read(&mut owner, &mut host, owner_fd, &mut value).unwrap(); + assert_eq!(u64::from_le_bytes(value), 37); + sys_close(&mut owner, &mut host, owner_fd).unwrap(); } #[test] - fn test_setenv_rejects_name_with_equals() { - let mut proc = Process::new(1); + fn legacy_exec_into_fresh_table_rejects_reused_special_backing() { + use crate::process_table::ProcessTable; + + const OWNER: u32 = 970_830; + const EXEC_PID: u32 = 970_831; + let mut owner = Process::new(OWNER); + let owner_fd = sys_eventfd2(&mut owner, 41, 0).unwrap(); + let backing_idx = descriptor_backing_idx(&owner, owner_fd); + let generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + + let mut replacement = Process::new(EXEC_PID); + let stale_handle = -((backing_idx as i64) + 1); + let stale_ofd = replacement.ofd_table.create( + FileType::EventFd, + O_RDWR, + stale_handle, + b"/dev/eventfd".to_vec(), + ); + replacement + .fd_table + .alloc(OpenFileDescRef(stale_ofd), 0) + .unwrap(); + + let mut table = ProcessTable::new(); assert_eq!( - sys_setenv(&mut proc, b"A=B", b"value", true), - Err(Errno::EINVAL) + table.replace_legacy_exec_process(EXEC_PID, replacement), + Err(Errno::EBADF) + ); + assert!(table.get(EXEC_PID).is_none()); + assert_eq!( + descriptor_backing_ref_count(FileType::EventFd, backing_idx), + Some(1) + ); + assert_eq!( + descriptor_backing_generation(FileType::EventFd, backing_idx), + Some(generation) ); - } - #[test] - fn test_mmap_anonymous() { - let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x22, -1, 0).unwrap(); // PROT_READ|WRITE, MAP_PRIVATE|ANON - assert_ne!(addr, 0xFFFFFFFF); + let mut value = [0u8; 8]; + sys_read(&mut owner, &mut host, owner_fd, &mut value).unwrap(); + assert_eq!(u64::from_le_bytes(value), 41); + sys_close(&mut owner, &mut host, owner_fd).unwrap(); } #[test] - fn test_mmap_file_backed_private() { + fn test_fcntl_setlk_fallback_conflict_reports_would_block() { + let _guard = enter_fallback_lock_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // Open a file to get a valid fd - let fd = sys_open(&mut proc, &mut host, b"/tmp/mmaptest", 0x42, 0o644).unwrap(); // O_CREAT|O_RDWR - // MAP_PRIVATE without MAP_ANONYMOUS should succeed (host populates data) - let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, fd, 0).unwrap(); // PROT_READ|WRITE, MAP_PRIVATE - assert_ne!(addr, 0xFFFFFFFF); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 100, + }, + ); + + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLK, &mut flock, &mut host); + assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_mmap_file_backed_bad_fd() { + fn test_fcntl_setlkw_fallback_conflict_uses_cooperative_retry() { + let _guard = enter_fallback_lock_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // MAP_PRIVATE with invalid fd should fail - let result = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, 99, 0); - assert_eq!(result, Err(Errno::EBADF)); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 100, + }, + ); + + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host); + assert_eq!(result, Err(Errno::EAGAIN)); } - #[test] - fn test_mmap_file_backed_shared() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // Open a file to get a valid fd - let fd = sys_open(&mut proc, &mut host, b"/tmp/mmaptest_shared", 0x42, 0o644).unwrap(); // O_CREAT|O_RDWR - // MAP_SHARED should succeed (allocates region, host does population + tracking) - let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x01, fd, 0).unwrap(); // PROT_READ|WRITE, MAP_SHARED - assert_ne!(addr, 0xFFFFFFFF); + struct FallbackLockTestGuard { + _guard: std::sync::MutexGuard<'static, ()>, } - #[test] - fn test_munmap() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x22, -1, 0).unwrap(); - sys_munmap(&mut proc, &mut host, addr, 0x10000).unwrap(); + impl Drop for FallbackLockTestGuard { + fn drop(&mut self) { + unsafe { crate::lock::global_fallback_lock_table().clear() }; + } } - #[test] - fn test_munmap_invalid_address_minus_one() { - // munmap((void*)-1, 1) — address 0xFFFFFFFF is not page-aligned, should return EINVAL - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - assert_eq!( - sys_munmap(&mut proc, &mut host, 0xFFFFFFFF, 1), - Err(Errno::EINVAL) + fn enter_fallback_lock_test() -> FallbackLockTestGuard { + let guard = crate::lock::FALLBACK_LOCK_TEST_LOCK.lock().unwrap(); + unsafe { crate::lock::global_fallback_lock_table().clear() }; + FallbackLockTestGuard { _guard: guard } + } + + fn add_fallback_pipe_fd(proc: &mut Process, host_handle: i64, status_flags: u32) -> i32 { + let ofd_idx = proc.ofd_table.create( + FileType::Pipe, + status_flags, + host_handle, + b"/dev/pipe".to_vec(), ); + proc.fd_table.alloc(OpenFileDescRef(ofd_idx), 0).unwrap() + } + + fn write_lock(start: i64, len: i64) -> WasmFlock { + WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: start, + l_len: len, + l_pid: 0, + _pad2: 0, + } } #[test] - fn test_munmap_address_overflow() { - // Page-aligned address where addr+len overflows usize + fn test_fcntl_getlk_fallback_conflict_reports_blocking_owner() { + let _guard = enter_fallback_lock_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let addr = usize::MAX & !0xFFFF; // page-aligned near max - assert_eq!( - sys_munmap(&mut proc, &mut host, addr, 0x20000), - Err(Errno::EINVAL) + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 7, + lock_type: F_WRLCK, + start: 10, + len: 25, + }, ); + + let mut query = WasmFlock { + l_type: F_RDLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 20, + l_len: 5, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock(&mut proc, write_fd, F_GETLK, &mut query, &mut host).unwrap(); + + assert_eq!(query.l_type as u32, F_WRLCK); + assert_eq!(query.l_pid, 7); + assert_eq!(query.l_start, 10); + assert_eq!(query.l_len, 25); + assert_eq!(query.l_whence, 0); } #[test] - fn test_munmap_unaligned_address() { - // munmap with non-page-aligned address should return EINVAL - let mut proc = Process::new(1); + fn test_fallback_locks_are_kernel_wide() { + let _guard = enter_fallback_lock_test(); + let mut proc1 = Process::new(1); + let mut proc2 = Process::new(2); let mut host = MockHostIO::new(); + let shared_handle = -700; + let fd1 = add_fallback_pipe_fd(&mut proc1, shared_handle, O_RDWR); + let fd2 = add_fallback_pipe_fd(&mut proc2, shared_handle, O_RDWR); + + let mut flock = write_lock(0, 100); + sys_fcntl_lock(&mut proc1, fd1, F_SETLK, &mut flock, &mut host).unwrap(); + + assert!( + proc1 + .lock_table + .get_blocking_lock(shared_handle, F_WRLCK, 0, 100, 2) + .is_none(), + "kernel fallback locks must not be stored on proc1" + ); + assert!( + proc2 + .lock_table + .get_blocking_lock(shared_handle, F_WRLCK, 0, 100, 1) + .is_none(), + "kernel fallback locks must not be stored on proc2" + ); + + let mut conflict = write_lock(0, 100); assert_eq!( - sys_munmap(&mut proc, &mut host, 0x1000, 0x10000), - Err(Errno::EINVAL) + sys_fcntl_lock(&mut proc2, fd2, F_SETLK, &mut conflict, &mut host), + Err(Errno::EAGAIN) + ); + + let mut query = WasmFlock { + l_type: F_RDLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 50, + l_len: 10, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock(&mut proc2, fd2, F_GETLK, &mut query, &mut host).unwrap(); + assert_eq!(query.l_type as u32, F_WRLCK); + assert_eq!(query.l_pid, 1); + assert_eq!(query.l_start, 0); + assert_eq!(query.l_len, 100); + + let mut unlock = WasmFlock { + l_type: F_UNLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock(&mut proc1, fd1, F_SETLK, &mut unlock, &mut host).unwrap(); + sys_fcntl_lock(&mut proc2, fd2, F_SETLK, &mut conflict, &mut host).unwrap(); + + assert!( + unsafe { crate::lock::global_fallback_lock_table() } + .get_blocking_lock(shared_handle, F_WRLCK, 0, 100, 1) + .is_some() ); } #[test] - fn test_munmap_zero_length() { + fn test_flock_fallback_nonblocking_conflict_reports_would_block() { + let _guard = enter_fallback_lock_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let host_handle = fd_host_handle(&proc, write_fd); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 0, + }, + ); + assert_eq!( - sys_munmap(&mut proc, &mut host, 0x10000, 0), - Err(Errno::EINVAL) + sys_flock(&mut proc, write_fd, LOCK_EX | LOCK_NB, &mut host), + Err(Errno::EAGAIN) ); } #[test] - fn test_munmap_rejects_host_reserved_region() { + fn test_flock_blocking_fallback_conflict_uses_cooperative_retry() { + let _guard = enter_fallback_lock_test(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let addr = proc.memory.reserve_host_region(0x10000); - assert_ne!(addr, MAP_FAILED); + let host_handle = -701; + let write_fd = add_fallback_pipe_fd(&mut proc, host_handle, O_RDWR); - assert_eq!( - sys_munmap(&mut proc, &mut host, addr, 0x10000), - Err(Errno::EINVAL) + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 0, + }, ); + + let result = sys_flock(&mut proc, write_fd, LOCK_EX, &mut host); + assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_brk_query() { - let mut proc = Process::new(1); - let brk = sys_brk(&mut proc, 0); - assert!(brk > 0); + fn test_fcntl_setlkw_fallback_succeeds_after_unlock() { + let _guard = enter_fallback_lock_test(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let host_handle = -702; + let write_fd = add_fallback_pipe_fd(&mut proc, host_handle, O_RDWR); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_WRLCK, + start: 0, + len: 100, + }, + ); + + let mut flock = write_lock(0, 100); + let result = sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host); + assert_eq!(result, Err(Errno::EAGAIN)); + + unsafe { crate::lock::global_fallback_lock_table() }.set_lock( + host_handle, + FileLock { + pid: 2, + lock_type: F_UNLCK, + start: 0, + len: 100, + }, + ); + + sys_fcntl_lock(&mut proc, write_fd, F_SETLKW, &mut flock, &mut host).unwrap(); + assert!( + unsafe { crate::lock::global_fallback_lock_table() } + .get_blocking_lock(host_handle, F_WRLCK, 0, 100, 2) + .is_some() + ); } #[test] - fn test_brk_set() { + fn test_close_releases_fcntl_locks() { let mut proc = Process::new(1); - let initial = sys_brk(&mut proc, 0); - let new_brk = sys_brk(&mut proc, initial + 4096); - assert_eq!(new_brk, initial + 4096); - } + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/lockfile", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); - #[test] - fn test_mprotect_succeeds_noop() { - let proc = Process::new(1); - assert_eq!(sys_mprotect(&proc, 0, 4096, 3), Ok(())); + // Acquire a write lock (delegates to host for host-backed files) + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock(&mut proc, fd, F_SETLK, &mut flock, &mut host).unwrap(); + + // Close the fd — should release all locks on this file (delegates unlock to host) + sys_close(&mut proc, &mut host, fd).unwrap(); + + // Verify the fd was actually closed + assert!(proc.fd_table.get(fd).is_err()); } #[test] - fn test_socket_creation() { + fn test_exit_releases_fcntl_locks() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(fd >= 3); - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_eq!(ofd.file_type, FileType::Socket); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/lockfile", + O_RDWR | O_CREAT, + 0o644, + ) + .unwrap(); + + // Acquire a write lock (delegates to host for host-backed files) + let mut flock = WasmFlock { + l_type: F_WRLCK as i16, + l_whence: 0, + _pad1: 0, + l_start: 0, + l_len: 100, + l_pid: 0, + _pad2: 0, + }; + sys_fcntl_lock(&mut proc, fd, F_SETLK, &mut flock, &mut host).unwrap(); + + // Exit the process — should release all locks (both local and via host) + sys_exit(&mut proc, &mut host, 0); + + // Process should be in Exited state + assert_eq!(proc.state, ProcessState::Exited); + // Local lock table should also be cleaned + assert!( + proc.lock_table + .get_blocking_lock(0, F_WRLCK as u32, 0, 100, 999) + .is_none() + ); } #[test] - fn test_socket_unsupported_domain() { - let mut proc = Process::new(1); + fn test_clock_gettime() { + let proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_socket(&mut proc, &mut host, 999, 1, 0); - assert_eq!(result, Err(Errno::EAFNOSUPPORT)); + let ts = sys_clock_gettime(&proc, &mut host, 0).unwrap(); + assert_eq!(ts.tv_sec, 1234567890); + assert_eq!(ts.tv_nsec, 123456789); } #[test] - fn test_socket_unsupported_type() { - let mut proc = Process::new(1); + fn test_clock_gettime_supports_boottime_and_encoded_process_clock() { + let proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::AF_UNIX; - let result = sys_socket(&mut proc, &mut host, AF_UNIX, 999, 0); - assert_eq!(result, Err(Errno::EPROTOTYPE)); + + assert!(sys_clock_gettime( + &proc, + &mut host, + wasm_posix_shared::clock::CLOCK_BOOTTIME, + ) + .is_ok()); + // Linux's clock_getcpuclockid() encoding for PID 1. + assert!(sys_clock_gettime(&proc, &mut host, (-2_i32 * 8 + 2) as u32).is_ok()); } #[test] - fn test_socketpair_unix_stream() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(fd0 >= 3); - assert!(fd1 >= 3); - assert_ne!(fd0, fd1); + fn test_clock_calls_map_linux_coarse_clocks_to_supported_sources() { + use wasm_posix_shared::clock::{ + CLOCK_MONOTONIC, CLOCK_MONOTONIC_COARSE, CLOCK_REALTIME, CLOCK_REALTIME_COARSE, + }; - // Write through fd0, read from fd1 - let n = sys_write(&mut proc, &mut host, fd0, b"hello").unwrap(); - assert_eq!(n, 5); - let mut buf = [0u8; 5]; - let n = sys_read(&mut proc, &mut host, fd1, &mut buf).unwrap(); - assert_eq!(n, 5); - assert_eq!(&buf, b"hello"); + let proc = Process::new(1); + let mut host = MockHostIO::new(); - // Write through fd1, read from fd0 (bidirectional) - let n = sys_write(&mut proc, &mut host, fd1, b"world").unwrap(); - assert_eq!(n, 5); - let mut buf = [0u8; 5]; - let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); - assert_eq!(n, 5); - assert_eq!(&buf, b"world"); + assert_eq!(host_clock_id(CLOCK_REALTIME_COARSE), Ok(CLOCK_REALTIME)); + assert_eq!(host_clock_id(CLOCK_MONOTONIC_COARSE), Ok(CLOCK_MONOTONIC),); + for clock_id in [CLOCK_REALTIME_COARSE, CLOCK_MONOTONIC_COARSE] { + assert!(sys_clock_gettime(&proc, &mut host, clock_id).is_ok()); + assert!(sys_clock_getres(&proc, clock_id).is_ok()); + } } #[test] - fn test_socketpair_close_one_end() { - let mut proc = Process::new(1); + fn test_clock_gettime_rejects_unknown_clock() { + let proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - sys_close(&mut proc, &mut host, fd1).unwrap(); + assert!(matches!( + sys_clock_gettime(&proc, &mut host, 0x7fff_ffff), + Err(Errno::EINVAL), + )); + } - // Write to fd0 should return EPIPE (peer closed) - let result = sys_write(&mut proc, &mut host, fd0, b"test"); - assert_eq!(result, Err(Errno::EPIPE)); + #[test] + fn test_clock_calls_reject_positive_cpu_clock_bit_pattern() { + let proc = Process::new(1); + let mut host = MockHostIO::new(); - // Read from fd0 should return 0 (EOF) - let mut buf = [0u8; 4]; - let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); - assert_eq!(n, 0); + // musl computes 10 for clock_getcpuclockid(-2). The kernel must return + // EINVAL so musl can translate that API result to ESRCH. + assert!(matches!( + sys_clock_gettime(&proc, &mut host, 10), + Err(Errno::EINVAL), + )); + assert!(matches!( + sys_clock_getres(&proc, 10), + Err(Errno::EINVAL), + )); } #[test] - fn test_write_broken_pipe_raises_sigpipe() { - let mut proc = Process::new(1); + fn test_nanosleep_rejects_negative() { + let proc = Process::new(1); let mut host = MockHostIO::new(); - let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let req = WasmTimespec { + tv_sec: -1, + tv_nsec: 0, + }; + assert_eq!(sys_nanosleep(&proc, &mut host, &req), Err(Errno::EINVAL)); + } - // Close read end - sys_close(&mut proc, &mut host, read_fd).unwrap(); + #[test] + fn test_nanosleep_rejects_invalid_nsec() { + let proc = Process::new(1); + let mut host = MockHostIO::new(); + let req = WasmTimespec { + tv_sec: 0, + tv_nsec: 1_000_000_000, + }; + assert_eq!(sys_nanosleep(&proc, &mut host, &req), Err(Errno::EINVAL)); + } - // Write should fail with EPIPE and raise SIGPIPE - let result = sys_write(&mut proc, &mut host, write_fd, b"data"); - assert_eq!(result, Err(Errno::EPIPE)); - assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + #[test] + fn test_nanosleep_valid() { + let proc = Process::new(1); + let mut host = MockHostIO::new(); + let req = WasmTimespec { + tv_sec: 1, + tv_nsec: 500_000_000, + }; + assert_eq!(sys_nanosleep(&proc, &mut host, &req), Ok(())); } #[test] - fn test_write_shutdown_socket_raises_sigpipe() { - let mut proc = Process::new(1); + fn test_clock_nanosleep_boottime_absolute_uses_monotonic_equivalent() { + let proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let req = WasmTimespec { + tv_sec: 1_234_567_891, + tv_nsec: 123_456_789, + }; + let mut relative = [0_u8; 16]; - sys_shutdown(&mut proc, &mut host, fd0, SHUT_WR).unwrap(); + assert_eq!( + sys_clock_nanosleep( + &proc, + &mut host, + wasm_posix_shared::clock::CLOCK_BOOTTIME, + 1, + &req, + relative.as_mut_ptr(), + ), + Ok(()), + ); + assert_eq!(i64::from_le_bytes(relative[0..8].try_into().unwrap()), 1); + assert_eq!(i64::from_le_bytes(relative[8..16].try_into().unwrap()), 0); + } - let result = sys_write(&mut proc, &mut host, fd0, b"test"); - assert_eq!(result, Err(Errno::EPIPE)); - assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + #[test] + fn test_isatty_stdin() { + let proc = terminal_process(1); + assert_eq!(sys_isatty(&proc, 0), Ok(1)); } #[test] - fn test_socketpair_close_both_frees_pipes() { + fn test_isatty_regular_file() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY, 0o644).unwrap(); + assert_eq!(sys_isatty(&proc, fd), Err(Errno::ENOTTY)); + } - // Get the pipe indices used by these sockets - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let ofd0 = proc - .ofd_table - .get(proc.fd_table.get(fd0).unwrap().ofd_ref.0) - .unwrap(); - let sock0_idx = (-(ofd0.host_handle + 1)) as usize; - let send0 = proc.sockets.get(sock0_idx).unwrap().send_buf_idx.unwrap(); - let recv0 = proc.sockets.get(sock0_idx).unwrap().recv_buf_idx.unwrap(); + #[test] + fn test_isatty_invalid_fd() { + let proc = Process::new(1); + assert_eq!(sys_isatty(&proc, 99), Err(Errno::EBADF)); + } - // Verify pipes exist before close - assert!(pipe_table.get(send0).is_some()); - assert!(pipe_table.get(recv0).is_some()); - - // Close both fds — pipe buffers should be freed - sys_close(&mut proc, &mut host, fd0).unwrap(); - sys_close(&mut proc, &mut host, fd1).unwrap(); - - // Verify the specific pipe slots are freed - assert!( - pipe_table.get(send0).is_none(), - "send pipe should be freed after both sockets close" - ); - assert!( - pipe_table.get(recv0).is_none(), - "recv pipe should be freed after both sockets close" - ); + #[test] + fn test_setenv_getenv() { + let mut proc = Process::new(1); + sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); + let mut buf = [0u8; 256]; + let n = sys_getenv(&proc, b"HOME", &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/home/user"); } #[test] - fn test_socketpair_uses_global_pipes() { + fn test_setenv_no_overwrite() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; + sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); + sys_setenv(&mut proc, b"HOME", b"/other", false).unwrap(); + let mut buf = [0u8; 256]; + let n = sys_getenv(&proc, b"HOME", &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/home/user"); + } - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + #[test] + fn test_setenv_overwrite() { + let mut proc = Process::new(1); + sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); + sys_setenv(&mut proc, b"HOME", b"/other", true).unwrap(); + let mut buf = [0u8; 256]; + let n = sys_getenv(&proc, b"HOME", &mut buf).unwrap(); + assert_eq!(&buf[..n], b"/other"); + } - // Both sockets should use global pipes (for cross-fork sharing) - let ofd0 = proc - .ofd_table - .get(proc.fd_table.get(fd0).unwrap().ofd_ref.0) - .unwrap(); - let sock0_idx = (-(ofd0.host_handle + 1)) as usize; - assert!( - proc.sockets.get(sock0_idx).unwrap().global_pipes, - "sock_a should use global pipes" - ); + #[test] + fn test_unsetenv() { + let mut proc = Process::new(1); + sys_setenv(&mut proc, b"HOME", b"/home/user", true).unwrap(); + sys_unsetenv(&mut proc, b"HOME").unwrap(); + let mut buf = [0u8; 256]; + assert_eq!(sys_getenv(&proc, b"HOME", &mut buf), Err(Errno::ENOENT)); + } - let ofd1 = proc - .ofd_table - .get(proc.fd_table.get(fd1).unwrap().ofd_ref.0) - .unwrap(); - let sock1_idx = (-(ofd1.host_handle + 1)) as usize; - assert!( - proc.sockets.get(sock1_idx).unwrap().global_pipes, - "sock_b should use global pipes" - ); + #[test] + fn test_getenv_not_found() { + let proc = Process::new(1); + let mut buf = [0u8; 256]; + assert_eq!(sys_getenv(&proc, b"NONEXIST", &mut buf), Err(Errno::ENOENT)); + } - // Verify pipes exist in the global table - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let send0 = proc.sockets.get(sock0_idx).unwrap().send_buf_idx.unwrap(); - let recv0 = proc.sockets.get(sock0_idx).unwrap().recv_buf_idx.unwrap(); - assert!( - pipe_table.get(send0).is_some(), - "send pipe should be in global table" - ); - assert!( - pipe_table.get(recv0).is_some(), - "recv pipe should be in global table" + #[test] + fn test_setenv_rejects_empty_name() { + let mut proc = Process::new(1); + assert_eq!( + sys_setenv(&mut proc, b"", b"value", true), + Err(Errno::EINVAL) ); - - sys_close(&mut proc, &mut host, fd0).unwrap(); - sys_close(&mut proc, &mut host, fd1).unwrap(); } #[test] - fn test_socketpair_not_unix() { + fn test_setenv_rejects_name_with_equals() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let result = sys_socketpair(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0); - assert_eq!(result, Err(Errno::EAFNOSUPPORT)); + assert_eq!( + sys_setenv(&mut proc, b"A=B", b"value", true), + Err(Errno::EINVAL) + ); } #[test] - fn test_shutdown_read() { + fn test_mmap_anonymous() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - sys_shutdown(&mut proc, &mut host, fd0, SHUT_RD).unwrap(); - let mut buf = [0u8; 4]; - let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); - assert_eq!(n, 0); + let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x22, -1, 0).unwrap(); // PROT_READ|WRITE, MAP_PRIVATE|ANON + assert_ne!(addr, 0xFFFFFFFF); } #[test] - fn test_shutdown_write() { + fn test_mmap_file_backed_private() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - sys_shutdown(&mut proc, &mut host, fd0, SHUT_WR).unwrap(); - let result = sys_write(&mut proc, &mut host, fd0, b"test"); - assert_eq!(result, Err(Errno::EPIPE)); + // Open a file to get a valid fd + let fd = sys_open(&mut proc, &mut host, b"/tmp/mmaptest", 0x42, 0o644).unwrap(); // O_CREAT|O_RDWR + // MAP_PRIVATE without MAP_ANONYMOUS should succeed (host populates data) + let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, fd, 0).unwrap(); // PROT_READ|WRITE, MAP_PRIVATE + assert_ne!(addr, 0xFFFFFFFF); } #[test] - fn test_send_recv() { + fn test_mmap_file_backed_bad_fd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - let n = sys_send(&mut proc, &mut host, fd0, b"test", 0).unwrap(); - assert_eq!(n, 4); - - let mut buf = [0u8; 4]; - let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0).unwrap(); - assert_eq!(n, 4); - assert_eq!(&buf, b"test"); + // MAP_PRIVATE with invalid fd should fail + let result = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x02, 99, 0); + assert_eq!(result, Err(Errno::EBADF)); } #[test] - fn test_recv_msg_peek() { + fn test_mmap_file_backed_shared() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - // Write data via fd0 - sys_send(&mut proc, &mut host, fd0, b"peek test", 0).unwrap(); - - // Peek via fd1 — data should be returned but not consumed - let mut buf = [0u8; 32]; - let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 2).unwrap(); // MSG_PEEK=2 - assert_eq!(n, 9); - assert_eq!(&buf[..9], b"peek test"); - - // Regular read — data should still be there - let n2 = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0).unwrap(); - assert_eq!(n2, 9); - assert_eq!(&buf[..9], b"peek test"); - - // Now data is consumed — use MSG_DONTWAIT to avoid blocking - let result = sys_recv(&mut proc, &mut host, fd1, &mut buf, MSG_DONTWAIT); - assert_eq!(result, Err(Errno::EAGAIN)); + // Open a file to get a valid fd + let fd = sys_open(&mut proc, &mut host, b"/tmp/mmaptest_shared", 0x42, 0o644).unwrap(); // O_CREAT|O_RDWR + // MAP_SHARED should succeed (allocates region, host does population + tracking) + let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x01, fd, 0).unwrap(); // PROT_READ|WRITE, MAP_SHARED + assert_ne!(addr, 0xFFFFFFFF); } #[test] - fn test_recv_msg_waitall() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + fn mmap_writeback_capability_requires_a_writable_host_regular_file() { + fn install_fd( + proc: &mut Process, + file_type: FileType, + flags: u32, + host_handle: i64, + ) -> i32 { + let ofd = proc.ofd_table.create( + file_type, + flags, + host_handle, + b"/mapped".to_vec(), + ); + proc.fd_table.alloc(OpenFileDescRef(ofd), 0).unwrap() + } - // Write 10 bytes in two chunks - sys_send(&mut proc, &mut host, fd0, b"hello", 0).unwrap(); - sys_send(&mut proc, &mut host, fd0, b"world", 0).unwrap(); + let mut proc = Process::new(41); + let host_rdwr = install_fd(&mut proc, FileType::Regular, O_RDWR, 50); + let host_wronly = install_fd(&mut proc, FileType::Regular, O_WRONLY, 51); + let host_rdonly = install_fd(&mut proc, FileType::Regular, O_RDONLY, 52); + let invalid_access = install_fd(&mut proc, FileType::Regular, O_ACCMODE, 54); + let synthetic = install_fd(&mut proc, FileType::Regular, O_RDWR, -100); + let memfd = install_fd(&mut proc, FileType::MemFd, O_RDWR, -1); + let device = install_fd(&mut proc, FileType::CharDevice, O_RDWR, -5); + let directory = install_fd(&mut proc, FileType::Directory, O_RDWR, 53); - // Recv with MSG_WAITALL should get all 10 bytes at once - let mut buf = [0u8; 10]; - let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0x100).unwrap(); // MSG_WAITALL=0x100 - assert_eq!(n, 10); - assert_eq!(&buf, b"helloworld"); + assert!(fd_supports_mmap_writeback(&proc, host_rdwr)); + assert!(!fd_supports_mmap_writeback(&proc, host_wronly)); + assert!(!fd_supports_mmap_writeback(&proc, host_rdonly)); + assert!(!fd_supports_mmap_writeback(&proc, invalid_access)); + assert!(!fd_supports_mmap_writeback(&proc, synthetic)); + assert!(!fd_supports_mmap_writeback(&proc, memfd)); + assert!(!fd_supports_mmap_writeback(&proc, device)); + assert!(!fd_supports_mmap_writeback(&proc, directory)); + assert!(!fd_supports_mmap_writeback(&proc, 999)); } #[test] - fn test_recv_msg_waitall_peer_closed() { + fn test_munmap() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - // Write only 5 bytes, then close - sys_send(&mut proc, &mut host, fd0, b"short", 0).unwrap(); - sys_close(&mut proc, &mut host, fd0).unwrap(); - - // MSG_WAITALL should return short read when peer closes - let mut buf = [0u8; 20]; - let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0x100).unwrap(); - assert_eq!(n, 5); - assert_eq!(&buf[..5], b"short"); + let addr = sys_mmap(&mut proc, &mut host, 0, 4096, 3, 0x22, -1, 0).unwrap(); + sys_munmap(&mut proc, &mut host, addr, 0x10000).unwrap(); } #[test] - fn test_send_msg_nosignal() { + fn test_munmap_rounds_length_before_releasing_pages() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let addr = sys_mmap(&mut proc, &mut host, 0, 0x20000, 3, 0x22, -1, 0).unwrap(); - // Close peer to trigger EPIPE on write - sys_close(&mut proc, &mut host, fd1).unwrap(); + sys_munmap(&mut proc, &mut host, addr, 0x10001).unwrap(); - // Send with MSG_NOSIGNAL — should get EPIPE but NOT raise SIGPIPE - let result = sys_send(&mut proc, &mut host, fd0, b"test", MSG_NOSIGNAL); - assert_eq!(result, Err(Errno::EPIPE)); - assert!(!proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); + assert!(!proc.memory.is_mapped(addr)); + assert!(!proc.memory.is_mapped(addr + 0x10000)); } #[test] - fn test_send_not_connected() { + fn test_munmap_invalid_address_minus_one() { + // munmap((void*)-1, 1) — address 0xFFFFFFFF is not page-aligned, should return EINVAL let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let result = sys_send(&mut proc, &mut host, fd, b"test", 0); - assert_eq!(result, Err(Errno::ENOTCONN)); + assert_eq!( + sys_munmap(&mut proc, &mut host, 0xFFFFFFFF, 1), + Err(Errno::EINVAL) + ); } #[test] - fn test_getsockopt_so_type() { + fn test_munmap_address_overflow() { + // Page-aligned address where addr+len overflows usize let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let val = sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_TYPE).unwrap(); - assert_eq!(val, SOCK_STREAM); + let addr = usize::MAX & !0xFFFF; // page-aligned near max + assert_eq!( + sys_munmap(&mut proc, &mut host, addr, 0x20000), + Err(Errno::EINVAL) + ); } #[test] - fn test_getsockopt_so_domain() { + fn test_munmap_unaligned_address() { + // munmap with non-page-aligned address should return EINVAL let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let val = sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_DOMAIN).unwrap(); - assert_eq!(val, AF_UNIX); + assert_eq!( + sys_munmap(&mut proc, &mut host, 0x1000, 0x10000), + Err(Errno::EINVAL) + ); } #[test] - fn test_getsockopt_not_socket() { + fn test_munmap_zero_length() { let mut proc = Process::new(1); - use wasm_posix_shared::socket::*; - let result = sys_getsockopt(&mut proc, 0, SOL_SOCKET, SO_TYPE); - assert_eq!(result, Err(Errno::ENOTSOCK)); + let mut host = MockHostIO::new(); + assert_eq!( + sys_munmap(&mut proc, &mut host, 0x10000, 0), + Err(Errno::EINVAL) + ); } #[test] - fn test_setsockopt_tcp_nodelay() { + fn test_munmap_rejects_host_reserved_region() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_NODELAY, 1).is_ok()); - let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_NODELAY).unwrap(); - assert_eq!(val, 1); + let addr = proc.memory.reserve_host_region(0x10000); + assert_ne!(addr, MAP_FAILED); + + assert_eq!( + sys_munmap(&mut proc, &mut host, addr, 0x10000), + Err(Errno::EINVAL) + ); } #[test] - fn test_setsockopt_so_linger() { + fn test_brk_query() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(sys_setsockopt(&mut proc, fd, SOL_SOCKET, SO_LINGER, 5).is_ok()); - let val = sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_LINGER).unwrap(); - assert_eq!(val, 5); + let brk = sys_brk(&mut proc, 0); + assert!(brk > 0); } #[test] - fn test_setsockopt_so_broadcast() { + fn test_brk_set() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - assert!(sys_setsockopt(&mut proc, fd, SOL_SOCKET, SO_BROADCAST, 1).is_ok()); + let initial = sys_brk(&mut proc, 0); + let new_brk = sys_brk(&mut proc, initial + 4096); + assert_eq!(new_brk, initial + 4096); } #[test] - fn test_getsockopt_default_tcp_nodelay() { + fn test_mprotect_succeeds_noop() { + let proc = Process::new(1); + assert_eq!(sys_mprotect(&proc, 0, 4096, 3), Ok(())); + } + + #[test] + fn test_socket_creation() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_NODELAY).unwrap(); - assert_eq!(val, 0); // default + assert!(fd >= 3); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!(ofd.file_type, FileType::Socket); } #[test] - fn test_getsockopt_tcp_info_established() { + fn listener_wake_resolver_finds_fd_above_default_nofile_limit() { + use crate::socket::SocketState; + use wasm_posix_shared::socket::{AF_INET, SOCK_STREAM}; + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - // Bind + listen + mark connected - let mut addr = [0u8; 16]; - addr[0] = 2; // AF_INET - sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); - // tcp_info should work on bound socket - let buf = sys_getsockopt_tcp_info(&proc, fd).unwrap(); - assert_eq!(buf[0], 7); // TCP_CLOSE (not connected/listening yet) - assert_eq!(buf.len(), TCP_INFO_SIZE); - // Check plausible MSS at offset 16 (u32 LE) - let snd_mss = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); - assert_eq!(snd_mss, 1460); + sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); + let low_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let sock_idx = test_socket_idx(&proc, low_fd); + let listener = proc.sockets.get_mut(sock_idx).unwrap(); + listener.state = SocketState::Listening; + listener.accept_wake_idx = Some(77); + + let high_fd = sys_fcntl(&mut proc, low_fd, F_DUPFD, 2048).unwrap(); + assert_eq!(high_fd, 2048); + sys_close(&mut proc, &mut host, low_fd).unwrap(); + + assert_eq!(find_listener_fd_by_accept_wake(&proc, 77), Some(high_fd)); + assert_eq!(find_listener_fd_by_accept_wake(&proc, 78), None); + sys_close(&mut proc, &mut host, high_fd).unwrap(); } #[test] - fn test_getsockopt_tcp_info_listening() { + fn test_socket_unsupported_domain() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let mut addr = [0u8; 16]; - addr[0] = 2; - sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); - sys_listen(&mut proc, &mut host, fd, 5).unwrap(); - let buf = sys_getsockopt_tcp_info(&proc, fd).unwrap(); - assert_eq!(buf[0], 10); // TCP_LISTEN + let result = sys_socket(&mut proc, &mut host, 999, 1, 0); + assert_eq!(result, Err(Errno::EAFNOSUPPORT)); } #[test] - fn test_getsockopt_tcp_info_not_socket() { - let proc = Process::new(1); - let result = sys_getsockopt_tcp_info(&proc, 0); - assert_eq!(result, Err(Errno::ENOTSOCK)); + fn test_socket_unsupported_type() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::AF_UNIX; + let result = sys_socket(&mut proc, &mut host, AF_UNIX, 999, 0); + assert_eq!(result, Err(Errno::EPROTOTYPE)); } #[test] - fn test_setsockopt_tcp_keepalive_options() { + fn test_socketpair_unix_stream() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - // Set and get TCP_KEEPIDLE - sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPIDLE, 60).unwrap(); - let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPIDLE).unwrap(); - assert_eq!(val, 60); - // Set and get TCP_KEEPINTVL - sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPINTVL, 10).unwrap(); - let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPINTVL).unwrap(); - assert_eq!(val, 10); - // Set and get TCP_KEEPCNT - sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPCNT, 5).unwrap(); - let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPCNT).unwrap(); - assert_eq!(val, 5); + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + assert!(fd0 >= 3); + assert!(fd1 >= 3); + assert_ne!(fd0, fd1); + + // Write through fd0, read from fd1 + let n = sys_write(&mut proc, &mut host, fd0, b"hello").unwrap(); + assert_eq!(n, 5); + let mut buf = [0u8; 5]; + let n = sys_read(&mut proc, &mut host, fd1, &mut buf).unwrap(); + assert_eq!(n, 5); + assert_eq!(&buf, b"hello"); + + // Write through fd1, read from fd0 (bidirectional) + let n = sys_write(&mut proc, &mut host, fd1, b"world").unwrap(); + assert_eq!(n, 5); + let mut buf = [0u8; 5]; + let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); + assert_eq!(n, 5); + assert_eq!(&buf, b"world"); } #[test] - fn test_shutdown_not_socket() { + fn exec_transfer_keeps_connected_socket_and_queued_bytes() { + use wasm_posix_shared::socket::*; + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let result = sys_shutdown(&mut proc, &mut host, 0, SHUT_RDWR); - assert_eq!(result, Err(Errno::ENOTSOCK)); + let (writer, reader) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_write(&mut proc, &mut host, writer, b"queued before exec").unwrap(); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + let mut buf = [0u8; 18]; + let n = sys_read(&mut proc, &mut host, reader, &mut buf).unwrap(); + assert_eq!(n, buf.len()); + assert_eq!(&buf, b"queued before exec"); + sys_close(&mut proc, &mut host, writer).unwrap(); + sys_close(&mut proc, &mut host, reader).unwrap(); } #[test] - fn test_bind_inet_succeeds() { - let mut proc = Process::new(1); + fn exec_preserves_stopped_state_and_parent_visible_status_record() { + use wasm_posix_shared::signal::SIGTSTP; + use wasm_posix_shared::wait::EVENT_STOPPED; + + let mut proc = Process::new(19); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - // sockaddr_in: family=AF_INET(2), port=8080 (BE), addr=0.0.0.0 - let mut addr = [0u8; 16]; - addr[0] = 2; // AF_INET - addr[2] = 0x1F; - addr[3] = 0x90; // port 8080 big-endian - let result = sys_bind(&mut proc, &mut host, fd, &addr); - assert_eq!(result, Ok(())); + assert!(proc.record_stop(SIGTSTP)); + + commit_exec_state(&mut proc, &mut host, 19).unwrap(); + + assert_eq!(proc.state, ProcessState::Stopped); + let event = proc.wait_event.unwrap(); + assert_eq!(event.event_mask, EVENT_STOPPED); + assert_eq!(event.si_status, SIGTSTP as i32); } #[test] - fn test_bind_enotsock() { + fn exec_cannot_resurrect_an_exited_process() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_bind(&mut proc, &mut host, 0, &[0u8; 16]); - assert_eq!(result, Err(Errno::ENOTSOCK)); + proc.state = crate::process::ProcessState::Exited; + proc.exit_status = 23; + let pid = proc.pid; + + assert_eq!( + commit_exec_state(&mut proc, &mut host, pid), + Err(Errno::ESRCH), + ); + assert_eq!(proc.state, crate::process::ProcessState::Exited); + assert_eq!(proc.exit_status, 23); + assert!(!proc.has_exec); } #[test] - fn test_bind_unix_stream() { + fn exec_keeps_queued_unix_accept_state_through_surviving_listener_dup() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); - let mut proc = Process::new(9010); - let mut host = MockHostIO::new(); - let path = b"/tmp/test_9010.sock"; - // Clean up any stale registration - let resolved = crate::path::resolve_path(path, &proc.cwd); + use wasm_posix_shared::socket::{AF_UNIX, SOCK_CLOEXEC, SOCK_STREAM}; + + const PID: u32 = 0x6eec_0010; + let path = b"/tmp/exec-listener-survives.sock"; + let resolved = crate::path::resolve_path(path, b"/"); unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); - let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); // AF_UNIX, SOCK_STREAM - // sockaddr_un: family(2) + path - let mut addr = [0u8; 110]; - addr[0] = 1; // AF_UNIX - addr[1] = 0; - addr[2..2 + path.len()].copy_from_slice(path); - sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - // Socket should be in Bound state - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = proc.sockets.get(sock_idx).unwrap(); - assert_eq!(sock.state, crate::socket::SocketState::Bound); - assert!(sock.bind_path.is_some()); + let mut proc = Process::new(PID); + let mut host = MockHostIO::new(); + let cloexec_listener = + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) + .unwrap(); + let addr = test_unix_addr(path); + sys_bind(&mut proc, &mut host, cloexec_listener, &addr).unwrap(); + sys_listen(&mut proc, &mut host, cloexec_listener, 4).unwrap(); - // Clean up + // dup() clears FD_CLOEXEC while retaining the same open file + // description and listener identity. + let listener = sys_dup(&mut proc, cloexec_listener).unwrap(); + assert_eq!(proc.fd_table.get(listener).unwrap().fd_flags, 0); + + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client, &addr).unwrap(); + let listener_sock_idx = test_socket_idx(&proc, listener); + let shared_idx = proc + .sockets + .get(listener_sock_idx) + .unwrap() + .shared_backlog_idx + .unwrap(); + assert_eq!( + unsafe { crate::socket::shared_listener_backlog_table() }.len(shared_idx), + 1 + ); + + commit_exec_state(&mut proc, &mut host, PID).unwrap(); + + assert!(proc.fd_table.get(cloexec_listener).is_err()); + assert_eq!(test_socket_idx(&proc, listener), listener_sock_idx); + let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries + [shared_idx]; + assert!(backlog.in_use); + assert_eq!(backlog.ref_count, 1); + assert_eq!(backlog.queue.len(), 1); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_some()); + + let accepted = sys_accept(&mut proc, &mut host, listener).unwrap(); + assert_eq!(sys_send(&mut proc, &mut host, client, b"after exec", 0), Ok(10)); + let mut buf = [0u8; 10]; + assert_eq!( + sys_recv(&mut proc, &mut host, accepted, &mut buf, 0), + Ok(buf.len()) + ); + assert_eq!(&buf, b"after exec"); + + sys_close(&mut proc, &mut host, accepted).unwrap(); + sys_close(&mut proc, &mut host, client).unwrap(); + sys_close(&mut proc, &mut host, listener).unwrap(); unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); } #[test] - fn test_bind_unix_duplicate_fails() { + fn exec_cloexec_last_unix_listener_abandons_queued_connection() { let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); - let mut proc = Process::new(9011); - let mut host = MockHostIO::new(); - let path = b"/tmp/dup_9011.sock"; - // Clean up any stale registration - let resolved = crate::path::resolve_path(path, &proc.cwd); + use wasm_posix_shared::signal::SIGPIPE; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_CLOEXEC, SOCK_STREAM}; + + const PID: u32 = 0x6eec_0011; + let path = b"/tmp/exec-listener-closes.sock"; + let resolved = crate::path::resolve_path(path, b"/"); unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); - let fd1 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let fd2 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; // AF_UNIX - addr[2..2 + path.len()].copy_from_slice(path); - let addrlen = 2 + path.len() + 1; - sys_bind(&mut proc, &mut host, fd1, &addr[..addrlen]).unwrap(); - let err = sys_bind(&mut proc, &mut host, fd2, &addr[..addrlen]).unwrap_err(); - assert_eq!(err, Errno::EADDRINUSE); + let mut proc = Process::new(PID); + let mut host = MockHostIO::new(); + let listener = + sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0) + .unwrap(); + let addr = test_unix_addr(path); + sys_bind(&mut proc, &mut host, listener, &addr).unwrap(); + sys_listen(&mut proc, &mut host, listener, 4).unwrap(); - // Clean up - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + let client = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client, &addr).unwrap(); + let listener_sock_idx = test_socket_idx(&proc, listener); + let shared_idx = proc + .sockets + .get(listener_sock_idx) + .unwrap() + .shared_backlog_idx + .unwrap(); + let (recv_pipe_idx, send_pipe_idx) = { + let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries + [shared_idx]; + assert!(backlog.in_use); + assert_eq!(backlog.ref_count, 1); + assert_eq!(backlog.queue.len(), 1); + ( + backlog.queue[0].recv_pipe_idx, + backlog.queue[0].send_pipe_idx, + ) + }; + + commit_exec_state(&mut proc, &mut host, PID).unwrap(); + + assert!(proc.fd_table.get(listener).is_err()); + assert!(proc.sockets.get(listener_sock_idx).is_none()); + assert!(unsafe { crate::unix_socket::global_unix_socket_registry() } + .lookup(&resolved) + .is_none()); + let backlog = &unsafe { crate::socket::shared_listener_backlog_table() }.entries + [shared_idx]; + assert!(!backlog.in_use); + assert_eq!(backlog.ref_count, 0); + assert!(backlog.queue.is_empty()); + + let pipes = unsafe { crate::pipe::global_pipe_table() }; + assert!(!pipes.get(recv_pipe_idx).unwrap().has_readers()); + assert!(!pipes.get(send_pipe_idx).unwrap().is_write_end_open()); + assert_eq!( + sys_send(&mut proc, &mut host, client, b"orphaned", 0), + Err(Errno::EPIPE) + ); + assert!(proc.signals.is_pending(SIGPIPE)); + let mut buf = [0u8; 1]; + assert_eq!(sys_recv(&mut proc, &mut host, client, &mut buf, 0), Ok(0)); + + sys_close(&mut proc, &mut host, client).unwrap(); + let pipes = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipes.get(recv_pipe_idx).is_none()); + assert!(pipes.get(send_pipe_idx).is_none()); } #[test] - fn test_listen_after_bind_succeeds() { + fn exec_keeps_eventfd_epoll_timerfd_signalfd_and_memfd_state() { + use wasm_posix_shared::signal::SIGINT; + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - // Must bind before listen - let mut addr = [0u8; 16]; - addr[0] = 2; - sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); - let result = sys_listen(&mut proc, &mut host, fd, 5); - assert_eq!(result, Ok(())); + + let eventfd = sys_eventfd2(&mut proc, 5, O_NONBLOCK).unwrap(); + let epollfd = sys_epoll_create1(&mut proc, 0).unwrap(); + sys_epoll_ctl(&mut proc, epollfd, 1, eventfd, POLLIN as u32, 0xfeed).unwrap(); + + host.clock_time = (100, 0); + let timerfd = sys_timerfd_create(&mut proc, 0, O_NONBLOCK).unwrap(); + sys_timerfd_settime(&mut proc, &mut host, timerfd, 0, 0, 0, 5, 0).unwrap(); + + let signal_mask = crate::signal::sig_bit(SIGINT); + let signalfd = sys_signalfd4(&mut proc, -1, signal_mask, O_NONBLOCK).unwrap(); + proc.signals.raise(SIGINT); + + let memfd = sys_memfd_create(&mut proc, b"exec-state", 0).unwrap(); + sys_write(&mut proc, &mut host, memfd, b"before exec").unwrap(); + sys_lseek(&mut proc, &mut host, memfd, 7, SEEK_SET).unwrap(); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + let (count, events) = + sys_epoll_pwait(&mut proc, &mut host, epollfd, 1, 0, None).unwrap(); + assert_eq!(count, 1); + assert_eq!(events[0].1, 0xfeed); + + let mut counter = [0u8; 8]; + sys_read(&mut proc, &mut host, eventfd, &mut counter).unwrap(); + assert_eq!(u64::from_le_bytes(counter), 5); + + host.clock_time = (106, 0); + let mut expirations = [0u8; 8]; + sys_read(&mut proc, &mut host, timerfd, &mut expirations).unwrap(); + assert_eq!(u64::from_le_bytes(expirations), 1); + + let mut signal_info = [0u8; 128]; + sys_read(&mut proc, &mut host, signalfd, &mut signal_info).unwrap(); + assert_eq!(u32::from_le_bytes(signal_info[0..4].try_into().unwrap()), SIGINT); + + let mut suffix = [0u8; 4]; + sys_read(&mut proc, &mut host, memfd, &mut suffix).unwrap(); + assert_eq!(&suffix, b"exec"); + + for fd in [epollfd, eventfd, timerfd, signalfd, memfd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } } #[test] - fn test_accept_eagain_on_empty_backlog() { + fn exec_closes_only_cloexec_alias_and_keeps_backing_object() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let mut addr = [0u8; 16]; - addr[0] = 2; - sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); - sys_listen(&mut proc, &mut host, fd, 5).unwrap(); - let result = sys_accept(&mut proc, &mut host, fd); - assert_eq!(result, Err(Errno::EAGAIN)); + let cloexec_fd = sys_eventfd2(&mut proc, 19, O_CLOEXEC).unwrap(); + let retained_fd = sys_dup(&mut proc, cloexec_fd).unwrap(); + let backing_idx = { + let entry = proc.fd_table.get(retained_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); + assert_eq!(proc.fd_table.get(retained_fd).unwrap().fd_flags, 0); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.fd_table.get(cloexec_fd).is_err()); + let mut value = [0u8; 8]; + sys_read(&mut proc, &mut host, retained_fd, &mut value).unwrap(); + assert_eq!(u64::from_le_bytes(value), 19); + sys_close(&mut proc, &mut host, retained_fd).unwrap(); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); } #[test] - fn test_connect_econnrefused() { + fn exec_cloexec_pipe_writer_leaves_buffer_then_eof() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let result = sys_connect(&mut proc, &mut host, fd, &[0u8; 16]); - assert_eq!(result, Err(Errno::ECONNREFUSED)); + let (reader, writer) = sys_pipe(&mut proc).unwrap(); + sys_write(&mut proc, &mut host, writer, b"queued").unwrap(); + proc.fd_table.get_mut(writer).unwrap().fd_flags |= FD_CLOEXEC; + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.fd_table.get(writer).is_err()); + let mut buf = [0u8; 6]; + assert_eq!(sys_read(&mut proc, &mut host, reader, &mut buf), Ok(6)); + assert_eq!(&buf, b"queued"); + assert_eq!(sys_read(&mut proc, &mut host, reader, &mut buf), Ok(0)); + sys_close(&mut proc, &mut host, reader).unwrap(); } #[test] - fn test_poll_regular_file_always_ready() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - // stdout (fd 1) should be ready for writing - let mut pollfd = WasmPollFd { - fd: 1, - events: POLLOUT, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLOUT, 0); - } + fn exec_promotes_calling_thread_signals_and_resets_image_state() { + use crate::process::{PosixTimerState, ThreadInfo}; + use crate::signal::SignalHandler; + use wasm_posix_shared::signal::{SIGINT, SIGTERM}; - #[test] - fn test_poll_pipe_readable() { - let mut proc = Process::new(1); + let mut proc = Process::new(10); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - - // Pipe is empty — not readable yet - let mut pollfd = WasmPollFd { - fd: read_fd, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 0); - assert_eq!(pollfd.revents, 0); + let mut caller = ThreadInfo::new(11, 0, 0, 0); + caller.signals.blocked = crate::signal::sig_bit(SIGTERM); + caller.signals.raise_with_value(32, 101); + caller.signals.raise_with_value(32, 202); + proc.add_thread(caller); + proc.add_thread(ThreadInfo::new(12, 0, 0, 0)); + proc.main_thread_signals.raise(SIGINT); + proc.signals + .set_handler(SIGINT, SignalHandler::Handler(0x1234)) + .unwrap(); + proc.alarm_deadline_ns = 8_000_000_000; + proc.alarm_interval_ns = 1_000_000_000; + proc.posix_timers.push(Some(PosixTimerState { + clock_id: 0, + sigev_signo: SIGINT, + sigev_value: 0, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 1, + interval_nsec: 0, + value_sec: 1, + value_nsec: 0, + notification_pending: false, + overrun_current: 0, + overrun_last: 0, + })); + + commit_exec_state(&mut proc, &mut host, 11).unwrap(); + + assert!(proc.threads.is_empty()); + assert_eq!(proc.signals.blocked, crate::signal::sig_bit(SIGTERM)); + assert_eq!(proc.signals.get_handler(SIGINT), SignalHandler::Default); + assert_eq!( + proc.main_thread_signals.pending & crate::signal::sig_bit(SIGINT), + 0 + ); + assert_eq!(proc.signals.pending & crate::signal::sig_bit(32), 0); + assert_eq!(proc.main_thread_signals.consume_one(32), Some((101, -1))); + assert_eq!(proc.main_thread_signals.consume_one(32), Some((202, -1))); + assert_eq!(proc.main_thread_signals.pending, 0); + assert_eq!(proc.alarm_deadline_ns, 8_000_000_000); + assert_eq!(proc.alarm_interval_ns, 1_000_000_000); + assert!(proc.posix_timers.is_empty()); + assert!(proc.has_exec); + } + + #[test] + fn exec_from_main_preserves_main_directed_pending_queue() { + use wasm_posix_shared::signal::SIGTERM; + + let mut proc = Process::new(20); + let mut host = MockHostIO::new(); + let pid = proc.pid; + proc.main_thread_signals.raise_with_value(32, 77); + proc.posix_timers.push(Some(crate::process::PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value: 88, + sigev_notify: 4, + sigev_tid: pid, + interval_sec: 0, + interval_nsec: 0, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 0, + overrun_last: 0, + })); + proc.main_thread_signals.raise_timer(10, 88, 0); + proc.signals.raise(SIGTERM); - // Write data into pipe - sys_write(&mut proc, &mut host, write_fd, b"data").unwrap(); + commit_exec_state(&mut proc, &mut host, pid).unwrap(); - // Now pipe should be readable - let mut pollfd = WasmPollFd { - fd: read_fd, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLIN, 0); + assert_eq!(proc.main_thread_signals.consume_one(32), Some((77, -1))); + assert!(!proc.main_thread_signals.is_pending(10)); + assert_eq!(proc.signals.consume_one(SIGTERM), Default::default()); } #[test] - fn test_poll_pipe_writable() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + fn exec_removes_discarded_pshared_participation() { + use crate::pshared::{MUTEX_TYPE_NORMAL, PTHREAD_BARRIER_SERIAL_THREAD}; - let mut pollfd = WasmPollFd { - fd: write_fd, - events: POLLOUT, - revents: 0, + const EXEC_PID: u32 = 0x6eec_0001; + const PEER_A: u32 = 0x6eec_0002; + const PEER_B: u32 = 0x6eec_0003; + + let (owned_mutex, cond_mutex, cond, barrier) = { + let table = unsafe { crate::pshared::global_pshared_table() }; + let owned_mutex = table.mutex_init(MUTEX_TYPE_NORMAL); + let cond_mutex = table.mutex_init(MUTEX_TYPE_NORMAL); + let cond = table.cond_init(); + let barrier = table.barrier_init(2).unwrap(); + + table.mutex_lock(owned_mutex, EXEC_PID).unwrap(); + table.mutex_lock(cond_mutex, EXEC_PID).unwrap(); + table + .cond_wait_begin(cond, cond_mutex, EXEC_PID) + .unwrap(); + assert_eq!(table.barrier_wait(barrier, EXEC_PID), Err(Errno::EAGAIN)); + (owned_mutex, cond_mutex, cond, barrier) }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLOUT, 0); - } - #[test] - fn test_poll_pipe_hangup() { - let mut proc = Process::new(1); + let mut proc = Process::new(EXEC_PID); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + commit_exec_state(&mut proc, &mut host, EXEC_PID).unwrap(); - sys_close(&mut proc, &mut host, write_fd).unwrap(); + let table = unsafe { crate::pshared::global_pshared_table() }; + assert_eq!(table.mutex_lock(owned_mutex, PEER_A), Ok(())); + assert_eq!(table.cond_signal(cond), Ok(0)); + assert_eq!(table.barrier_wait(barrier, PEER_A), Err(Errno::EAGAIN)); + assert_eq!( + table.barrier_wait(barrier, PEER_B), + Ok(PTHREAD_BARRIER_SERIAL_THREAD) + ); + assert_eq!(table.barrier_wait(barrier, PEER_A), Ok(0)); - let mut pollfd = WasmPollFd { - fd: read_fd, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLHUP, 0); + table.mutex_unlock(owned_mutex, PEER_A).unwrap(); + table.mutex_destroy(owned_mutex).unwrap(); + table.mutex_destroy(cond_mutex).unwrap(); + table.cond_destroy(cond).unwrap(); + table.barrier_destroy(barrier).unwrap(); } #[test] - fn test_poll_invalid_fd() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - let mut pollfd = WasmPollFd { - fd: 99, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLNVAL, 0); - } + fn exec_closes_dir_stream_but_keeps_raw_directory_fd_position() { + use crate::process::DirStream; - #[test] - fn test_poll_negative_fd_ignored() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - let mut pollfd = WasmPollFd { - fd: -1, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 0); - assert_eq!(pollfd.revents, 0); + let ofd_idx = proc.ofd_table.create( + FileType::Directory, + O_RDONLY, + 55, + b"/tmp".to_vec(), + ); + { + let ofd = proc.ofd_table.get_mut(ofd_idx).unwrap(); + ofd.dir_host_handle = 77; + ofd.dir_synth_state = 2; + ofd.dir_entry_offset = 9; + } + let dir_fd = proc + .fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .unwrap(); + proc.dir_streams.push(Some(DirStream { + host_handle: 88, + path: b"/tmp".to_vec(), + position: 4, + synth_dot_state: 2, + })); + + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.dir_streams.iter().all(Option::is_none)); + assert_eq!(host.closed_dir_handles, vec![88]); + let entry = proc.fd_table.get(dir_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!(ofd.dir_host_handle, 77); + assert_eq!(ofd.dir_synth_state, 2); + assert_eq!(ofd.dir_entry_offset, 9); + + sys_close(&mut proc, &mut host, dir_fd).unwrap(); + assert_eq!(host.closed_dir_handles, vec![88, 77]); } #[test] - fn test_poll_socket_pair() { + fn test_socketpair_close_one_end() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; use wasm_posix_shared::socket::*; let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - // Socket is writable (send buffer has space) - let mut pollfd = WasmPollFd { - fd: fd0, - events: POLLOUT, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLOUT, 0); + sys_close(&mut proc, &mut host, fd1).unwrap(); - // Socket is not readable (no data yet) - let mut pollfd = WasmPollFd { - fd: fd0, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 0); + // Write to fd0 should return EPIPE (peer closed) + let result = sys_write(&mut proc, &mut host, fd0, b"test"); + assert_eq!(result, Err(Errno::EPIPE)); - // Write to fd1, now fd0 is readable - sys_write(&mut proc, &mut host, fd1, b"x").unwrap(); - let mut pollfd = WasmPollFd { - fd: fd0, - events: POLLIN, - revents: 0, - }; - let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); - assert_eq!(n, 1); - assert_ne!(pollfd.revents & POLLIN, 0); + // Read from fd0 should return 0 (EOF) + let mut buf = [0u8; 4]; + let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); + assert_eq!(n, 0); } #[test] - fn test_poll_multiple_fds() { + fn test_write_broken_pipe_raises_sigpipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::WasmPollFd; - use wasm_posix_shared::poll::*; - use wasm_posix_shared::socket::*; - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let mut pollfds = [ - WasmPollFd { - fd: 1, - events: POLLOUT, - revents: 0, - }, - WasmPollFd { - fd: fd0, - events: POLLIN, - revents: 0, - }, - ]; - let n = sys_poll(&mut proc, &mut host, &mut pollfds, 0).unwrap(); - assert_eq!(n, 1); // Only stdout ready - assert_ne!(pollfds[0].revents & POLLOUT, 0); - assert_eq!(pollfds[1].revents, 0); - } + // Close read end + sys_close(&mut proc, &mut host, read_fd).unwrap(); - #[test] - fn test_lseek_seek_end_on_pipe() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); - // SEEK_END on pipe should return ESPIPE - let result = sys_lseek(&mut proc, &mut host, read_fd, 0, SEEK_END); - assert_eq!(result, Err(Errno::ESPIPE)); + // Write should fail with EPIPE and raise SIGPIPE + let result = sys_write(&mut proc, &mut host, write_fd, b"data"); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } #[test] - fn test_pread_espipe_on_pipe() { + fn test_write_shutdown_socket_raises_sigpipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); - let mut buf = [0u8; 4]; - let result = sys_pread(&mut proc, &mut host, read_fd, &mut buf, 0); - assert_eq!(result, Err(Errno::ESPIPE)); - } + use wasm_posix_shared::socket::*; + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - #[test] - fn test_pwrite_espipe_on_pipe() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let result = sys_pwrite(&mut proc, &mut host, write_fd, b"test", 0); - assert_eq!(result, Err(Errno::ESPIPE)); - } + sys_shutdown(&mut proc, &mut host, fd0, SHUT_WR).unwrap(); - #[test] - fn test_pread_einval_on_negative_offset() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/tmp/f", O_RDWR | O_CREAT, 0o644).unwrap(); - let mut buf = [0u8; 4]; - assert_eq!( - sys_pread(&mut proc, &mut host, fd, &mut buf, -1), - Err(Errno::EINVAL) - ); - assert_eq!( - sys_pread(&mut proc, &mut host, fd, &mut buf, i64::MIN), - Err(Errno::EINVAL) - ); + let result = sys_write(&mut proc, &mut host, fd0, b"test"); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } #[test] - fn test_pwrite_einval_on_negative_offset() { + fn test_write_external_tcp_epipe_raises_sigpipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/tmp/f", O_RDWR | O_CREAT, 0o644).unwrap(); - assert_eq!( - sys_pwrite(&mut proc, &mut host, fd, b"x", -1), - Err(Errno::EINVAL) - ); - assert_eq!( - sys_pwrite(&mut proc, &mut host, fd, b"x", i64::MIN), - Err(Errno::EINVAL) - ); + use wasm_posix_shared::socket::*; + + host.net_connect_result = Ok(()); + host.net_connect_status_result = Ok(()); + host.net_send_result = Err(Errno::EPIPE); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let result = sys_write(&mut proc, &mut host, fd, b"test"); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } #[test] - fn test_pread_espipe_on_socket() { + fn test_socketpair_close_both_frees_pipes() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let mut buf = [0u8; 4]; - let result = sys_pread(&mut proc, &mut host, fd, &mut buf, 0); - assert_eq!(result, Err(Errno::ESPIPE)); - } + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Get the pipe indices used by these sockets + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let ofd0 = proc + .ofd_table + .get(proc.fd_table.get(fd0).unwrap().ofd_ref.0) + .unwrap(); + let sock0_idx = (-(ofd0.host_handle + 1)) as usize; + let send0 = proc.sockets.get(sock0_idx).unwrap().send_buf_idx.unwrap(); + let recv0 = proc.sockets.get(sock0_idx).unwrap().recv_buf_idx.unwrap(); + + // Verify pipes exist before close + assert!(pipe_table.get(send0).is_some()); + assert!(pipe_table.get(recv0).is_some()); + + // Close both fds — pipe buffers should be freed + sys_close(&mut proc, &mut host, fd0).unwrap(); + sys_close(&mut proc, &mut host, fd1).unwrap(); + + // Verify the specific pipe slots are freed + assert!( + pipe_table.get(send0).is_none(), + "send pipe should be freed after both sockets close" + ); + assert!( + pipe_table.get(recv0).is_none(), + "recv pipe should be freed after both sockets close" + ); + } #[test] - fn test_time_returns_positive() { + fn test_socketpair_uses_global_pipes() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let t = sys_time(&mut proc, &mut host).unwrap(); - // MockHostIO returns (1234567890, 123456789) for clock_gettime - assert_eq!(t, 1234567890); + use wasm_posix_shared::socket::*; + + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Both sockets should use global pipes (for cross-fork sharing) + let ofd0 = proc + .ofd_table + .get(proc.fd_table.get(fd0).unwrap().ofd_ref.0) + .unwrap(); + let sock0_idx = (-(ofd0.host_handle + 1)) as usize; + assert!( + proc.sockets.get(sock0_idx).unwrap().global_pipes, + "sock_a should use global pipes" + ); + + let ofd1 = proc + .ofd_table + .get(proc.fd_table.get(fd1).unwrap().ofd_ref.0) + .unwrap(); + let sock1_idx = (-(ofd1.host_handle + 1)) as usize; + assert!( + proc.sockets.get(sock1_idx).unwrap().global_pipes, + "sock_b should use global pipes" + ); + + // Verify pipes exist in the global table + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let send0 = proc.sockets.get(sock0_idx).unwrap().send_buf_idx.unwrap(); + let recv0 = proc.sockets.get(sock0_idx).unwrap().recv_buf_idx.unwrap(); + assert!( + pipe_table.get(send0).is_some(), + "send pipe should be in global table" + ); + assert!( + pipe_table.get(recv0).is_some(), + "recv pipe should be in global table" + ); + + sys_close(&mut proc, &mut host, fd0).unwrap(); + sys_close(&mut proc, &mut host, fd1).unwrap(); } #[test] - fn test_gettimeofday() { + fn test_socketpair_not_unix() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (sec, usec) = sys_gettimeofday(&mut proc, &mut host).unwrap(); - assert_eq!(sec, 1234567890); - assert_eq!(usec, 123456); // 123456789 nsec / 1000 = 123456 usec + use wasm_posix_shared::socket::*; + let result = sys_socketpair(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0); + assert_eq!(result, Err(Errno::EAFNOSUPPORT)); } #[test] - fn test_usleep() { + fn test_shutdown_read() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // MockHostIO nanosleep is a no-op, so this should succeed - sys_usleep(&mut proc, &mut host, 1000).unwrap(); + use wasm_posix_shared::socket::*; + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_shutdown(&mut proc, &mut host, fd0, SHUT_RD).unwrap(); + let mut buf = [0u8; 4]; + let n = sys_read(&mut proc, &mut host, fd0, &mut buf).unwrap(); + assert_eq!(n, 0); } #[test] - fn test_openat_at_fdcwd() { + fn test_shutdown_write() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::flags::AT_FDCWD; - // AT_FDCWD should work like open() - let result = sys_openat(&mut proc, &mut host, AT_FDCWD, b"/tmp/test", O_RDONLY, 0); - // MockHostIO.host_open returns Ok(100), so we get a valid fd - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + sys_shutdown(&mut proc, &mut host, fd0, SHUT_WR).unwrap(); + let result = sys_write(&mut proc, &mut host, fd0, b"test"); + assert_eq!(result, Err(Errno::EPIPE)); } #[test] - fn test_openat_absolute_path_ignores_dirfd() { + fn test_shutdown_rdwr_consumes_pipe_refs_once() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // Absolute path should ignore dirfd - let result = sys_openat(&mut proc, &mut host, 999, b"/tmp/test", O_RDONLY, 0); - // Even with invalid dirfd, absolute path works - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + let (sock0_idx, send_idx, recv_idx) = { + let ofd = proc + .ofd_table + .get(proc.fd_table.get(fd0).unwrap().ofd_ref.0) + .unwrap(); + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).unwrap(); + (sock_idx, sock.send_buf_idx.unwrap(), sock.recv_buf_idx.unwrap()) + }; + + sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); + sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); + let sock = proc.sockets.get(sock0_idx).unwrap(); + assert!(sock.send_buf_idx.is_none()); + assert!(sock.recv_buf_idx.is_none()); + assert_eq!( + sys_send(&mut proc, &mut host, fd0, b"after-shutdown", MSG_NOSIGNAL), + Err(Errno::EPIPE), + ); + + sys_close(&mut proc, &mut host, fd0).unwrap(); + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipe_table.get(send_idx).is_some()); + assert!(pipe_table.get(recv_idx).is_some()); + + sys_close(&mut proc, &mut host, fd1).unwrap(); + assert!(pipe_table.get(send_idx).is_none()); + assert!(pipe_table.get(recv_idx).is_none()); } #[test] - fn test_openat_dirfd_enotdir() { + fn test_send_recv() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 0 is stdin pipe by default, not Directory. - let result = sys_openat(&mut proc, &mut host, 0, b"relative", O_RDONLY, 0); - assert_eq!(result, Err(Errno::ENOTDIR)); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + let n = sys_send(&mut proc, &mut host, fd0, b"test", 0).unwrap(); + assert_eq!(n, 4); + + let mut buf = [0u8; 4]; + let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0).unwrap(); + assert_eq!(n, 4); + assert_eq!(&buf, b"test"); } #[test] - fn test_fstatat_at_fdcwd() { + fn test_recv_msg_peek() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_fstatat(&mut proc, &mut host, AT_FDCWD, b"/tmp/test", 0); - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Write data via fd0 + sys_send(&mut proc, &mut host, fd0, b"peek test", 0).unwrap(); + + // Peek via fd1 — data should be returned but not consumed + let mut buf = [0u8; 32]; + let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 2).unwrap(); // MSG_PEEK=2 + assert_eq!(n, 9); + assert_eq!(&buf[..9], b"peek test"); + + // Regular read — data should still be there + let n2 = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0).unwrap(); + assert_eq!(n2, 9); + assert_eq!(&buf[..9], b"peek test"); + + // Now data is consumed — use MSG_DONTWAIT to avoid blocking + let result = sys_recv(&mut proc, &mut host, fd1, &mut buf, MSG_DONTWAIT); + assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_fstatat_absolute_path() { + fn test_recv_msg_waitall() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // Absolute path ignores dirfd - let result = sys_fstatat(&mut proc, &mut host, 5, b"/tmp/test", 0); - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Write 10 bytes in two chunks + sys_send(&mut proc, &mut host, fd0, b"hello", 0).unwrap(); + sys_send(&mut proc, &mut host, fd0, b"world", 0).unwrap(); + + // Recv with MSG_WAITALL should get all 10 bytes at once + let mut buf = [0u8; 10]; + let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0x100).unwrap(); // MSG_WAITALL=0x100 + assert_eq!(n, 10); + assert_eq!(&buf, b"helloworld"); } #[test] - fn test_fstatat_symlink_nofollow() { + fn test_recv_msg_waitall_peer_closed() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_fstatat( - &mut proc, - &mut host, - AT_FDCWD, - b"/tmp/link", - AT_SYMLINK_NOFOLLOW, - ); - assert!(result.is_ok()); - let stat = result.unwrap(); - // MockHostIO lstat returns S_IFLNK - assert_eq!(stat.st_mode & S_IFLNK, S_IFLNK); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Write only 5 bytes, then close + sys_send(&mut proc, &mut host, fd0, b"short", 0).unwrap(); + sys_close(&mut proc, &mut host, fd0).unwrap(); + + // MSG_WAITALL should return short read when peer closes + let mut buf = [0u8; 20]; + let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, 0x100).unwrap(); + assert_eq!(n, 5); + assert_eq!(&buf[..5], b"short"); } #[test] - fn test_fstatat_relative_invalid_dirfd() { + fn test_send_msg_nosignal() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_fstatat(&mut proc, &mut host, 5, b"relative", 0); - assert!(matches!(result, Err(Errno::EBADF))); + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Close peer to trigger EPIPE on write + sys_close(&mut proc, &mut host, fd1).unwrap(); + + // Send with MSG_NOSIGNAL — should get EPIPE but NOT raise SIGPIPE + let result = sys_send(&mut proc, &mut host, fd0, b"test", MSG_NOSIGNAL); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(!proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } #[test] - fn test_unlinkat_at_fdcwd() { + fn test_send_external_tcp_msg_nosignal_suppresses_sigpipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_unlinkat(&mut proc, &mut host, AT_FDCWD, b"/tmp/test", 0); - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + + host.net_connect_result = Ok(()); + host.net_connect_status_result = Ok(()); + host.net_send_result = Err(Errno::EPIPE); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let result = sys_send(&mut proc, &mut host, fd, b"test", MSG_NOSIGNAL); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(!proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } #[test] - fn test_unlinkat_removedir() { + fn test_send_external_tcp_epipe_raises_sigpipe() { + // Default flags (no MSG_NOSIGNAL): an EPIPE from the host bridge on an + // external TCP send must raise SIGPIPE, mirroring sys_write's path. let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_unlinkat(&mut proc, &mut host, AT_FDCWD, b"/tmp/dir", AT_REMOVEDIR); - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + + host.net_connect_result = Ok(()); + host.net_connect_status_result = Ok(()); + host.net_send_result = Err(Errno::EPIPE); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let result = sys_send(&mut proc, &mut host, fd, b"test", 0); + assert_eq!(result, Err(Errno::EPIPE)); + assert!(proc.signals.is_pending(wasm_posix_shared::signal::SIGPIPE)); } #[test] - fn test_unlinkat_relative_invalid_dirfd() { + fn test_send_not_connected() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_unlinkat(&mut proc, &mut host, 5, b"relative", 0); - assert_eq!(result, Err(Errno::EBADF)); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let result = sys_send(&mut proc, &mut host, fd, b"test", 0); + assert_eq!(result, Err(Errno::ENOTCONN)); } #[test] - fn test_mkdirat_at_fdcwd() { + fn test_getsockopt_so_type() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_mkdirat(&mut proc, &mut host, AT_FDCWD, b"/tmp/newdir", 0o755); - assert!(result.is_ok()); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let val = sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_TYPE).unwrap(); + assert_eq!(val, SOCK_STREAM); } #[test] - fn test_mkdirat_relative_invalid_dirfd() { + fn test_getsockopt_so_domain() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_mkdirat(&mut proc, &mut host, 5, b"relative", 0o755); - assert_eq!(result, Err(Errno::EBADF)); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let val = sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_DOMAIN).unwrap(); + assert_eq!(val, AF_UNIX); } #[test] - fn test_renameat_at_fdcwd() { + fn test_getsockopt_bindtodevice_defaults_to_empty_name() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_renameat( - &mut proc, - &mut host, - AT_FDCWD, - b"/tmp/old", - AT_FDCWD, - b"/tmp/new", - ); - assert!(result.is_ok()); - } + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); - #[test] - fn test_renameat_absolute_paths() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // Both paths absolute, dirfds are ignored - let result = sys_renameat(&mut proc, &mut host, 5, b"/tmp/old", 6, b"/tmp/new"); - assert!(result.is_ok()); + let name = sys_getsockopt_bindtodevice(&proc, fd).unwrap(); + + assert!(name.is_empty()); } #[test] - fn test_renameat_relative_invalid_dirfd() { + fn test_getsockopt_bindtodevice_returns_explicit_binding() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // Old path relative with non-existent dirfd - let result = sys_renameat(&mut proc, &mut host, 5, b"relative", AT_FDCWD, b"/tmp/new"); - assert_eq!(result, Err(Errno::EBADF)); - } + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); - #[test] - fn test_tcgetattr_returns_terminal_state() { - let mut proc = terminal_process(1); - let mut buf = [0u8; 60]; - let result = sys_tcgetattr(&mut proc, 0, &mut buf); - assert!(result.is_ok()); - // Verify c_lflag has ECHO set (in the 4th u32, bytes 12-15) - let c_lflag = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]); - assert!(c_lflag & 0o0010 != 0); // ECHO + sys_setsockopt_bindtodevice(&mut proc, fd, b"lo\0").unwrap(); + let name = sys_getsockopt_bindtodevice(&proc, fd).unwrap(); + + assert_eq!(name, b"lo"); } #[test] - fn test_tcgetattr_enotty_for_regular_file() { - let mut proc = Process::new(1); + fn test_bindtodevice_can_unbind_and_constrains_routes() { + let mut proc = Process::new(9035); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY, 0).unwrap(); - let mut buf = [0u8; 60]; - let result = sys_tcgetattr(&mut proc, fd, &mut buf); - assert_eq!(result, Err(Errno::ENOTTY)); - } + use wasm_posix_shared::socket::*; - #[test] - fn test_tcsetattr_modifies_terminal_state() { - let mut proc = terminal_process(1); - let mut buf = [0u8; 60]; - sys_tcgetattr(&mut proc, 0, &mut buf).unwrap(); - // Clear ECHO in c_lflag (4th u32, bytes 12-15) - let c_lflag = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]); - let new_lflag = c_lflag & !0o0010; // Clear ECHO - buf[12..16].copy_from_slice(&new_lflag.to_le_bytes()); - // Set attrs with TCSANOW=0 - let result = sys_tcsetattr(&mut proc, 0, 0, &buf); - assert!(result.is_ok()); - // Read back - let mut buf2 = [0u8; 60]; - sys_tcgetattr(&mut proc, 0, &mut buf2).unwrap(); - let c_lflag2 = u32::from_le_bytes([buf2[12], buf2[13], buf2[14], buf2[15]]); - assert_eq!(c_lflag2 & 0o0010, 0); // ECHO cleared - } + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + sys_setsockopt_bindtodevice(&mut proc, fd, b"lo\0").unwrap(); + let mut external = [0u8; 16]; + external[0] = AF_INET as u8; + external[2..4].copy_from_slice(&53u16.to_be_bytes()); + external[4..8].copy_from_slice(&[10, 88, 0, 2]); + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &external).unwrap_err(), + Errno::ENETUNREACH, + ); - #[test] - fn test_ioctl_tiocgwinsz() { - let mut proc = terminal_process(1); - let mut host = MockHostIO::new(); - let mut buf = [0u8; 8]; - let result = sys_ioctl(&mut proc, &mut host, 0, 0x5413, &mut buf); // TIOCGWINSZ - assert!(result.is_ok()); - let ws_row = u16::from_le_bytes([buf[0], buf[1]]); - let ws_col = u16::from_le_bytes([buf[2], buf[3]]); - assert_eq!(ws_row, 24); - assert_eq!(ws_col, 80); + sys_setsockopt_bindtodevice(&mut proc, fd, b"\0").unwrap(); + assert!(sys_getsockopt_bindtodevice(&proc, fd).unwrap().is_empty()); + sys_connect(&mut proc, &mut host, fd, &external).unwrap(); + + let unix = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_setsockopt_bindtodevice(&mut proc, unix, b"lo\0").unwrap_err(), + Errno::ENOPROTOOPT, + ); } #[test] - fn test_ioctl_tiocswinsz() { - let mut proc = terminal_process(1); + fn test_tcp_congestion_only_accepts_supported_tcp_policy() { + let mut proc = Process::new(9036); let mut host = MockHostIO::new(); - let mut buf = [0u8; 8]; - buf[0..2].copy_from_slice(&120u16.to_le_bytes()); // rows - buf[2..4].copy_from_slice(&200u16.to_le_bytes()); // cols - let result = sys_ioctl(&mut proc, &mut host, 0, 0x5414, &mut buf); // TIOCSWINSZ - assert!(result.is_ok()); - // Read back - let mut buf2 = [0u8; 8]; - sys_ioctl(&mut proc, &mut host, 0, 0x5413, &mut buf2).unwrap(); // TIOCGWINSZ - let ws_row = u16::from_le_bytes([buf2[0], buf2[1]]); - let ws_col = u16::from_le_bytes([buf2[2], buf2[3]]); - assert_eq!(ws_row, 120); - assert_eq!(ws_col, 200); + use wasm_posix_shared::socket::*; + + let tcp = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + assert_eq!(sys_getsockopt_tcp_congestion(&proc, tcp).unwrap(), b"cubic"); + sys_setsockopt_tcp_congestion(&mut proc, tcp, b"cubic\0").unwrap(); + assert_eq!( + sys_setsockopt_tcp_congestion(&mut proc, tcp, b"reno\0").unwrap_err(), + Errno::ENOENT, + ); + + let udp = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_getsockopt_tcp_congestion(&proc, udp).unwrap_err(), + Errno::ENOPROTOOPT, + ); + assert_eq!( + sys_setsockopt_tcp_congestion(&mut proc, udp, b"cubic\0").unwrap_err(), + Errno::ENOPROTOOPT, + ); } #[test] - fn test_ioctl_unsupported_returns_enotty() { + fn test_getsockopt_not_socket() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let mut buf = [0u8; 8]; - let result = sys_ioctl(&mut proc, &mut host, 0, 0x9999, &mut buf); - assert_eq!(result, Err(Errno::ENOTTY)); + use wasm_posix_shared::socket::*; + let result = sys_getsockopt(&mut proc, 0, SOL_SOCKET, SO_TYPE); + assert_eq!(result, Err(Errno::ENOTSOCK)); } #[test] - fn test_ioctl_fionbio_set_nonblock() { + fn test_setsockopt_tcp_nodelay() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // Set O_NONBLOCK via FIONBIO on stdout (fd 1) - let mut buf = 1i32.to_le_bytes(); - let result = sys_ioctl(&mut proc, &mut host, 1, 0x5421, &mut buf); - assert!(result.is_ok()); - let ofd = proc - .ofd_table - .get(proc.fd_table.get(1).unwrap().ofd_ref.0) - .unwrap(); - assert_ne!(ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK, 0); - // Clear it - let mut buf = 0i32.to_le_bytes(); - sys_ioctl(&mut proc, &mut host, 1, 0x5421, &mut buf).unwrap(); - let ofd = proc - .ofd_table - .get(proc.fd_table.get(1).unwrap().ofd_ref.0) - .unwrap(); - assert_eq!(ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK, 0); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + assert!(sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_NODELAY, 1).is_ok()); + let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_NODELAY).unwrap(); + assert_eq!(val, 1); } #[test] - fn test_ioctl_fioclex_fionclex() { + fn test_setsockopt_so_linger() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mut buf = [0u8; 4]; - // Set FD_CLOEXEC via FIOCLEX on fd 0 - sys_ioctl(&mut proc, &mut host, 0, 0x5451, &mut buf).unwrap(); - assert_ne!( - proc.fd_table.get(0).unwrap().fd_flags & wasm_posix_shared::fd_flags::FD_CLOEXEC, - 0 + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + assert_eq!(sys_getsockopt_linger(&proc, fd).unwrap(), (0, 0)); + + sys_setsockopt_linger(&mut proc, fd, 0, 5).unwrap(); + assert_eq!(sys_getsockopt_linger(&proc, fd).unwrap(), (0, 5)); + + assert_eq!( + sys_setsockopt_linger(&mut proc, fd, 1, 0), + Err(Errno::EOPNOTSUPP), ); - // Clear via FIONCLEX - sys_ioctl(&mut proc, &mut host, 0, 0x5450, &mut buf).unwrap(); assert_eq!( - proc.fd_table.get(0).unwrap().fd_flags & wasm_posix_shared::fd_flags::FD_CLOEXEC, - 0 + sys_setsockopt_linger(&mut proc, fd, 1, 5), + Err(Errno::EOPNOTSUPP), ); + assert_eq!(sys_getsockopt_linger(&proc, fd).unwrap(), (0, 5)); } #[test] - fn test_ioctl_fionread_pipe() { + fn test_setsockopt_so_broadcast() { let mut proc = Process::new(1); - let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); let mut host = MockHostIO::new(); - // Write some data - sys_write(&mut proc, &mut host, write_fd, b"hello").unwrap(); - // FIONREAD should return 5 - let mut buf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,read_fd, 0x541B, &mut buf).unwrap(); - let avail = i32::from_le_bytes(buf); - assert_eq!(avail, 5); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + assert!(sys_setsockopt(&mut proc, fd, SOL_SOCKET, SO_BROADCAST, 1).is_ok()); } #[test] - fn test_ioctl_fionread_regular() { - let mut proc = terminal_process(1); + fn test_getsockopt_default_tcp_nodelay() { + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // FIONREAD on a CharDevice returns 0 - let mut buf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host, 0, 0x541B, &mut buf).unwrap(); - let avail = i32::from_le_bytes(buf); - assert_eq!(avail, 0); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_NODELAY).unwrap(); + assert_eq!(val, 0); // default } - // ---- MSG_OOB / SIOCATMARK tests ---- - #[test] - fn test_send_recv_msg_oob() { + fn test_getsockopt_tcp_info_established() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; - - let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - // No OOB pending: SIOCATMARK returns 0 - let mut iobuf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); - assert_eq!(i32::from_le_bytes(iobuf), 0); - - // Send OOB byte from fd0 - let n = sys_send(&mut proc, &mut host, fd0, b"X", MSG_OOB).unwrap(); - assert_eq!(n, 1); - - // SIOCATMARK on fd1 returns 1 - let mut iobuf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); - assert_eq!(i32::from_le_bytes(iobuf), 1); - - // Recv OOB byte from fd1 - let mut buf = [0u8; 1]; - let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, MSG_OOB).unwrap(); - assert_eq!(n, 1); - assert_eq!(buf[0], b'X'); - - // After reading OOB, SIOCATMARK returns 0 - let mut iobuf = [0u8; 4]; - sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); - assert_eq!(i32::from_le_bytes(iobuf), 0); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + // Bind + listen + mark connected + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + // tcp_info should work on bound socket + let buf = sys_getsockopt_tcp_info(&proc, fd).unwrap(); + assert_eq!(buf[0], 7); // TCP_CLOSE (not connected/listening yet) + assert_eq!(buf.len(), TCP_INFO_SIZE); + // Check plausible MSS at offset 16 (u32 LE) + let snd_mss = u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]); + assert_eq!(snd_mss, 1460); } #[test] - fn test_recv_msg_oob_no_data() { + fn test_getsockopt_tcp_info_listening() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; - - let (_fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - // Recv OOB with no OOB pending returns EINVAL - let mut buf = [0u8; 1]; - let err = sys_recv(&mut proc, &mut host, fd1, &mut buf, MSG_OOB).unwrap_err(); - assert_eq!(err, Errno::EINVAL); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, fd, 5).unwrap(); + let buf = sys_getsockopt_tcp_info(&proc, fd).unwrap(); + assert_eq!(buf[0], 10); // TCP_LISTEN } - // ---- prctl tests ---- - #[test] - fn test_prctl_set_get_name() { - let mut proc = Process::new(1); - let mut buf = [0u8; 16]; - buf[..5].copy_from_slice(b"hello"); - sys_prctl(&mut proc, 15, 0, &mut buf).unwrap(); // PR_SET_NAME - let mut out = [0u8; 16]; - sys_prctl(&mut proc, 16, 0, &mut out).unwrap(); // PR_GET_NAME - assert_eq!(&out[..5], b"hello"); - assert_eq!(out[5], 0); + fn test_getsockopt_tcp_info_not_socket() { + let proc = Process::new(1); + let result = sys_getsockopt_tcp_info(&proc, 0); + assert_eq!(result, Err(Errno::ENOTSOCK)); } #[test] - fn test_prctl_unknown_is_noop() { + fn test_setsockopt_tcp_keepalive_options() { let mut proc = Process::new(1); - let mut buf = [0u8; 16]; - assert!(sys_prctl(&mut proc, 999, 0, &mut buf).is_ok()); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + // Set and get TCP_KEEPIDLE + sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPIDLE, 60).unwrap(); + let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPIDLE).unwrap(); + assert_eq!(val, 60); + // Set and get TCP_KEEPINTVL + sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPINTVL, 10).unwrap(); + let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPINTVL).unwrap(); + assert_eq!(val, 10); + // Set and get TCP_KEEPCNT + sys_setsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPCNT, 5).unwrap(); + let val = sys_getsockopt(&mut proc, fd, IPPROTO_TCP, TCP_KEEPCNT).unwrap(); + assert_eq!(val, 5); } #[test] - fn test_fcntl_f_getown_default_zero() { + fn test_shutdown_not_socket() { let mut proc = Process::new(1); - let result = sys_fcntl(&mut proc, 0, 9, 0); // F_GETOWN - assert_eq!(result, Ok(0)); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let result = sys_shutdown(&mut proc, &mut host, 0, SHUT_RDWR); + assert_eq!(result, Err(Errno::ENOTSOCK)); } #[test] - fn test_fcntl_f_setown_and_getown() { + fn test_bind_inet_succeeds() { let mut proc = Process::new(1); - // Set owner to pid 42 - let result = sys_fcntl(&mut proc, 0, 8, 42); // F_SETOWN - assert_eq!(result, Ok(0)); - // Get owner - let result = sys_fcntl(&mut proc, 0, 9, 0); // F_GETOWN - assert_eq!(result, Ok(42)); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + // sockaddr_in: family=AF_INET(2), port=8080 (BE), addr=0.0.0.0 + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + addr[2] = 0x1F; + addr[3] = 0x90; // port 8080 big-endian + let result = sys_bind(&mut proc, &mut host, fd, &addr); + assert_eq!(result, Ok(())); } #[test] - fn test_open_nofollow_passed_through() { + fn test_bind_enotsock() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::flags::O_NOFOLLOW; - // Open with O_NOFOLLOW should work for regular files - let result = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY | O_NOFOLLOW, 0); - assert!(result.is_ok()); - // Verify O_NOFOLLOW is NOT stored in status flags - let fd = result.unwrap(); - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_eq!(ofd.status_flags & O_NOFOLLOW, 0); // Not in status flags + let result = sys_bind(&mut proc, &mut host, 0, &[0u8; 16]); + assert_eq!(result, Err(Errno::ENOTSOCK)); } #[test] - fn test_pipe_read_nonblock_eagain() { - let mut proc = Process::new(1); + fn test_bind_unix_stream() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9010); let mut host = MockHostIO::new(); - let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); - // Set read end to non-blocking - sys_fcntl(&mut proc, read_fd, F_SETFL, O_NONBLOCK).unwrap(); - // Read with nothing in pipe — should get EAGAIN - let mut buf = [0u8; 16]; - let result = sys_read(&mut proc, &mut host, read_fd, &mut buf); - assert_eq!(result, Err(Errno::EAGAIN)); + let path = b"/tmp/test_9010.sock"; + // Clean up any stale registration + let resolved = crate::path::resolve_path(path, &proc.cwd); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); // AF_UNIX, SOCK_STREAM + // sockaddr_un: family(2) + path + let mut addr = [0u8; 110]; + addr[0] = 1; // AF_UNIX + addr[1] = 0; + addr[2..2 + path.len()].copy_from_slice(path); + sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); + // Socket should be in Bound state + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(sock.state, crate::socket::SocketState::Bound); + assert!(sock.bind_path.is_some()); + + // Clean up + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); } #[test] - fn test_pipe_read_nonblock_with_data() { - let mut proc = Process::new(1); + fn test_bind_unix_duplicate_fails() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9011); let mut host = MockHostIO::new(); - let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - sys_fcntl(&mut proc, read_fd, F_SETFL, O_NONBLOCK).unwrap(); - // Write some data - sys_write(&mut proc, &mut host, write_fd, b"hello").unwrap(); - // Read — should succeed - let mut buf = [0u8; 16]; - let n = sys_read(&mut proc, &mut host, read_fd, &mut buf).unwrap(); - assert_eq!(n, 5); - assert_eq!(&buf[..5], b"hello"); + let path = b"/tmp/dup_9011.sock"; + // Clean up any stale registration + let resolved = crate::path::resolve_path(path, &proc.cwd); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let fd1 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let fd2 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; // AF_UNIX + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut proc, &mut host, fd1, &addr[..addrlen]).unwrap(); + let err = sys_bind(&mut proc, &mut host, fd2, &addr[..addrlen]).unwrap_err(); + assert_eq!(err, Errno::EADDRINUSE); + + // Clean up + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); } #[test] - fn test_pipe_read_eof_when_write_end_closed() { + fn test_listen_after_bind_succeeds() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - sys_fcntl(&mut proc, read_fd, F_SETFL, O_NONBLOCK).unwrap(); - // Close write end - sys_close(&mut proc, &mut host, write_fd).unwrap(); - // Read — should get 0 (EOF), not EAGAIN - let mut buf = [0u8; 16]; - let n = sys_read(&mut proc, &mut host, read_fd, &mut buf).unwrap(); - assert_eq!(n, 0); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + // Must bind before listen + let mut addr = [0u8; 16]; + addr[0] = 2; + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + let result = sys_listen(&mut proc, &mut host, fd, 5); + assert_eq!(result, Ok(())); } #[test] - fn test_pipe_write_nonblock_eagain_when_full() { + fn test_accept_eagain_on_empty_backlog() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - sys_fcntl(&mut proc, write_fd, F_SETFL, O_NONBLOCK).unwrap(); - // Fill pipe buffer (64KB) - let big = [0u8; 65536]; - let n = sys_write(&mut proc, &mut host, write_fd, &big).unwrap(); - assert_eq!(n, 65536); - // Next write should get EAGAIN - let result = sys_write(&mut proc, &mut host, write_fd, b"x"); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, fd, 5).unwrap(); + let result = sys_accept(&mut proc, &mut host, fd); assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_blocking_pipe_read_returns_eagain_for_host_retry() { + fn test_connect_econnrefused() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); - - let mut buf = [0u8; 16]; - let result = sys_read(&mut proc, &mut host, read_fd, &mut buf); - assert_eq!(result, Err(Errno::EAGAIN)); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let result = sys_connect(&mut proc, &mut host, fd, &[0u8; 16]); + assert_eq!(result, Err(Errno::ECONNREFUSED)); } #[test] - fn test_blocking_pipe_write_returns_eagain_for_host_retry() { + fn test_external_nonblocking_connect_reports_pending_errnos_once_then_writable() { + use wasm_posix_shared::fcntl_cmd::F_SETFL; + use wasm_posix_shared::poll::POLLOUT; + use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - // Fill the pipe buffer - let big = [0u8; 65536]; - let n = sys_write(&mut proc, &mut host, write_fd, &big).unwrap(); - assert_eq!(n, 65536); + host.net_connect_result = Ok(()); + host.net_connect_status_result = Err(Errno::EAGAIN); - let result = sys_write(&mut proc, &mut host, write_fd, b"x"); - assert_eq!(result, Err(Errno::EAGAIN)); - } + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + sys_fcntl(&mut proc, fd, F_SETFL, O_NONBLOCK).unwrap(); + let addr = [2, 0, 0, 80, 203, 0, 113, 5, 0, 0, 0, 0, 0, 0, 0, 0]; - // ---- umask tests ---- + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(), + Errno::EINPROGRESS, + "the first pending external connect must report EINPROGRESS", + ); + assert_eq!(host.net_connect_calls.len(), 1); + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(), + Errno::EALREADY, + "a repeated pending external connect must report EALREADY", + ); + assert_eq!( + host.net_connect_calls.len(), + 1, + "retries must query the existing host connection, not start another", + ); - #[test] - fn test_umask_default() { - let proc = Process::new(1); - assert_eq!(proc.umask, 0o022); + host.net_connect_status_result = Ok(()); + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(pollfd.revents & POLLOUT, 0); + assert_eq!(sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_ERROR).unwrap(), 0); + assert_eq!(host.net_connect_calls.len(), 1); } #[test] - fn test_umask_set_and_get_old() { + fn test_external_nonblocking_connect_poll_failure_caches_and_clears_so_error() { + use wasm_posix_shared::fcntl_cmd::F_SETFL; + use wasm_posix_shared::poll::{POLLERR, POLLOUT}; + use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; + let mut proc = Process::new(1); - let old = sys_umask(&mut proc, 0o077); - assert_eq!(old, 0o022); // previous default - let old2 = sys_umask(&mut proc, 0o000); - assert_eq!(old2, 0o077); + let mut host = MockHostIO::new(); + host.net_connect_result = Ok(()); + host.net_connect_status_result = Err(Errno::EAGAIN); + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + sys_fcntl(&mut proc, fd, F_SETFL, O_NONBLOCK).unwrap(); + let addr = [2, 0, 0, 80, 203, 0, 113, 6, 0, 0, 0, 0, 0, 0, 0, 0]; + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(), + Errno::EINPROGRESS, + ); + + host.net_connect_status_result = Err(Errno::ECONNREFUSED); + let mut pollfd = WasmPollFd { + fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(pollfd.revents & POLLERR, 0); + assert_ne!(pollfd.revents & POLLOUT, 0); + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_ERROR).unwrap(), + Errno::ECONNREFUSED as u32, + ); + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_ERROR).unwrap(), + 0, + "SO_ERROR must clear the cached connect failure after it is read", + ); + assert_eq!(host.net_connect_calls.len(), 1); } #[test] - fn test_umask_masks_high_bits() { + fn test_poll_regular_file_always_ready() { let mut proc = Process::new(1); - let old = sys_umask(&mut proc, 0o7777); // only 0o777 stored - assert_eq!(old, 0o022); - assert_eq!(proc.umask, 0o777); - } - - // ---- uname tests ---- - - #[test] - fn test_uname_returns_fields() { - let mut buf = [0u8; 325]; - let result = sys_uname(&mut buf); - assert!(result.is_ok()); - // sysname at offset 0 - assert_eq!(&buf[0..10], b"wasm-posix"); - assert_eq!(buf[10], 0); // null terminated - // nodename at offset 65 - assert_eq!(&buf[65..74], b"localhost"); - // machine at offset 260 - assert_eq!(&buf[260..266], b"wasm32"); + let mut host = MockHostIO::new(); + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + // stdout (fd 1) should be ready for writing + let mut pollfd = WasmPollFd { + fd: 1, + events: POLLOUT, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLOUT, 0); } #[test] - fn test_uname_buffer_too_small() { - let mut buf = [0u8; 100]; - let result = sys_uname(&mut buf); - assert_eq!(result, Err(Errno::EINVAL)); - } + fn test_poll_pipe_readable() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - // ---- sysconf tests ---- + // Pipe is empty — not readable yet + let mut pollfd = WasmPollFd { + fd: read_fd, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 0); + assert_eq!(pollfd.revents, 0); - #[test] - fn test_sysconf_page_size() { - assert_eq!(sys_sysconf(30), Ok(65536)); // _SC_PAGE_SIZE - assert_eq!(sys_sysconf(11), Ok(65536)); // _SC_PAGESIZE - } + // Write data into pipe + sys_write(&mut proc, &mut host, write_fd, b"data").unwrap(); - #[test] - fn test_sysconf_open_max() { - assert_eq!(sys_sysconf(4), Ok(1024)); // _SC_OPEN_MAX + // Now pipe should be readable + let mut pollfd = WasmPollFd { + fd: read_fd, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLIN, 0); } #[test] - fn test_sysconf_nprocessors() { - assert_eq!(sys_sysconf(6), Ok(1)); // _SC_NPROCESSORS_ONLN - } + fn test_poll_pipe_writable() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - #[test] - fn test_sysconf_invalid() { - assert_eq!(sys_sysconf(9999), Err(Errno::EINVAL)); + let mut pollfd = WasmPollFd { + fd: write_fd, + events: POLLOUT, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLOUT, 0); } - // ---- pathconf/fpathconf tests ---- - #[test] - fn test_pathconf_name_max() { - assert_eq!(sys_pathconf(b"/tmp/foo", 4), Ok(255)); // _PC_NAME_MAX - } + fn test_poll_connected_inet6_datagram_matches_recv_filter() { + let mut proc = Process::new(9032); + let mut host = MockHostIO::new(); + use wasm_posix_shared::poll::POLLIN; + use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; - #[test] - fn test_pathconf_pipe_buf() { - assert_eq!(sys_pathconf(b"/tmp/foo", 6), Ok(4096)); // _PC_PIPE_BUF - } + let fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let idx = (-(ofd.host_handle + 1)) as usize; + let loopback = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + let sock = proc.sockets.get_mut(idx).unwrap(); + sock.state = crate::socket::SocketState::Connected; + sock.peer_addr6 = loopback; + sock.peer_port = 7000; + let mut wrong = crate::socket::Datagram { + data: b"wrong".to_vec(), + src_addr: [0; 4], + src_addr6: [0; 16], + dst_addr: [0; 4], + dst_addr6: loopback, + src_port: 7000, + src_sock_idx: None, + ipv6_tclass: 0, + src_pid: 0, + src_uid: 0, + src_gid: 0, + ancillary_fds: Vec::new(), + }; + sock.dgram_queue.push(wrong.clone()); - #[test] - fn test_pathconf_invalid_name() { - assert_eq!(sys_pathconf(b"/tmp/foo", 999), Err(Errno::EINVAL)); + let mut pfd = WasmPollFd { + fd, + events: POLLIN, + revents: 0, + }; + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), + 0 + ); + wrong.src_addr6 = loopback; + proc.sockets.get_mut(idx).unwrap().dgram_queue.push(wrong); + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), + 1 + ); + assert_ne!(pfd.revents & POLLIN, 0); } #[test] - fn test_fpathconf_valid_fd() { - let proc = Process::new(1); - // fd 0 is pre-opened (stdin) - assert_eq!(sys_fpathconf(&proc, 0, 5), Ok(4096)); // _PC_PATH_MAX - } + fn test_poll_connected_unix_datagram_includes_peer_pid() { + let mut proc = Process::new(9033); + let mut host = MockHostIO::new(); + use wasm_posix_shared::poll::POLLIN; + use wasm_posix_shared::socket::*; + use wasm_posix_shared::WasmPollFd; - #[test] - fn test_fpathconf_invalid_fd() { - let proc = Process::new(1); - assert_eq!(sys_fpathconf(&proc, 99, 5), Err(Errno::EBADF)); + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get_mut(idx).unwrap(); + sock.state = crate::socket::SocketState::Connected; + sock.peer_idx = Some(7); + let mut datagram = crate::socket::Datagram { + data: b"peer".to_vec(), + src_addr: [0; 4], + src_addr6: [0; 16], + dst_addr: [0; 4], + dst_addr6: [0; 16], + src_port: 0, + src_sock_idx: Some(7), + ipv6_tclass: 0, + src_pid: 9999, + src_uid: 0, + src_gid: 0, + ancillary_fds: Vec::new(), + }; + sock.dgram_queue.push(datagram.clone()); + let mut pfd = WasmPollFd { + fd, + events: POLLIN, + revents: 0, + }; + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), + 0 + ); + datagram.src_pid = proc.pid; + proc.sockets.get_mut(idx).unwrap().dgram_queue.push(datagram); + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pfd), 0).unwrap(), + 1 + ); } - // ---- getsockname/getpeername tests ---- - #[test] - fn test_getsockname_socket() { - use wasm_posix_shared::socket::*; + fn test_poll_pipe_hangup() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let mut buf = [0u8; 16]; - let n = sys_getsockname(&proc, fd0, &mut buf).unwrap(); - assert_eq!(n, 2); - assert_eq!(buf[0], 1); // AF_UNIX - assert_eq!(buf[1], 0); - } + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - #[test] - fn test_getsockname_non_socket() { - let proc = Process::new(1); - let mut buf = [0u8; 16]; - // fd 0 is stdin, not a socket - assert_eq!(sys_getsockname(&proc, 0, &mut buf), Err(Errno::ENOTSOCK)); + sys_close(&mut proc, &mut host, write_fd).unwrap(); + + let mut pollfd = WasmPollFd { + fd: read_fd, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLHUP, 0); } #[test] - fn test_getpeername_connected() { - use wasm_posix_shared::socket::*; + fn test_poll_invalid_fd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let mut buf = [0u8; 16]; - let n = sys_getpeername(&proc, fd0, &mut buf).unwrap(); - assert_eq!(n, 2); - assert_eq!(buf[0], 1); // AF_UNIX + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + let mut pollfd = WasmPollFd { + fd: 99, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLNVAL, 0); } - // ---- ftruncate tests ---- - #[test] - fn test_ftruncate_regular_file() { + fn test_poll_negative_fd_ignored() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - let result = sys_ftruncate(&mut proc, &mut host, fd, 100); - assert!(result.is_ok()); + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + let mut pollfd = WasmPollFd { + fd: -1, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 0); + assert_eq!(pollfd.revents, 0); } #[test] - fn test_ftruncate_negative_length() { + fn test_poll_socket_pair() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - let result = sys_ftruncate(&mut proc, &mut host, fd, -1); - assert_eq!(result, Err(Errno::EINVAL)); + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + use wasm_posix_shared::socket::*; + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // Socket is writable (send buffer has space) + let mut pollfd = WasmPollFd { + fd: fd0, + events: POLLOUT, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLOUT, 0); + + // Socket is not readable (no data yet) + let mut pollfd = WasmPollFd { + fd: fd0, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 0); + + // Write to fd1, now fd0 is readable + sys_write(&mut proc, &mut host, fd1, b"x").unwrap(); + let mut pollfd = WasmPollFd { + fd: fd0, + events: POLLIN, + revents: 0, + }; + let n = sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0).unwrap(); + assert_eq!(n, 1); + assert_ne!(pollfd.revents & POLLIN, 0); } #[test] - fn test_ftruncate_bad_fd() { + fn test_poll_multiple_fds() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_ftruncate(&mut proc, &mut host, 99, 0); - assert_eq!(result, Err(Errno::EBADF)); - } - - #[test] - fn test_ftruncate_pipe_einval() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let (r, _w) = sys_pipe(&mut proc).unwrap(); - let result = sys_ftruncate(&mut proc, &mut host, r, 0); - assert_eq!(result, Err(Errno::EINVAL)); + use wasm_posix_shared::WasmPollFd; + use wasm_posix_shared::poll::*; + use wasm_posix_shared::socket::*; + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + let mut pollfds = [ + WasmPollFd { + fd: 1, + events: POLLOUT, + revents: 0, + }, + WasmPollFd { + fd: fd0, + events: POLLIN, + revents: 0, + }, + ]; + let n = sys_poll(&mut proc, &mut host, &mut pollfds, 0).unwrap(); + assert_eq!(n, 1); // Only stdout ready + assert_ne!(pollfds[0].revents & POLLOUT, 0); + assert_eq!(pollfds[1].revents, 0); } #[test] - fn test_ftruncate_rdonly_einval() { + fn test_lseek_seek_end_on_pipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY, 0o644).unwrap(); - let result = sys_ftruncate(&mut proc, &mut host, fd, 0); - assert_eq!(result, Err(Errno::EINVAL)); + let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); + // SEEK_END on pipe should return ESPIPE + let result = sys_lseek(&mut proc, &mut host, read_fd, 0, SEEK_END); + assert_eq!(result, Err(Errno::ESPIPE)); } - // ---- fsync tests ---- - #[test] - fn test_fsync_regular_file() { + fn test_pread_espipe_on_pipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - let result = sys_fsync(&mut proc, &mut host, fd); - assert!(result.is_ok()); + let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); + let mut buf = [0u8; 4]; + let result = sys_pread(&mut proc, &mut host, read_fd, &mut buf, 0); + assert_eq!(result, Err(Errno::ESPIPE)); } #[test] - fn test_fsync_bad_fd() { + fn test_pwrite_espipe_on_pipe() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_fsync(&mut proc, &mut host, 99); - assert_eq!(result, Err(Errno::EBADF)); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let result = sys_pwrite(&mut proc, &mut host, write_fd, b"test", 0); + assert_eq!(result, Err(Errno::ESPIPE)); } #[test] - fn test_fsync_pipe_einval() { + fn test_pread_einval_on_negative_offset() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (r, _w) = sys_pipe(&mut proc).unwrap(); - let result = sys_fsync(&mut proc, &mut host, r); - assert_eq!(result, Err(Errno::EINVAL)); + let fd = sys_open(&mut proc, &mut host, b"/tmp/f", O_RDWR | O_CREAT, 0o644).unwrap(); + let mut buf = [0u8; 4]; + assert_eq!( + sys_pread(&mut proc, &mut host, fd, &mut buf, -1), + Err(Errno::EINVAL) + ); + assert_eq!( + sys_pread(&mut proc, &mut host, fd, &mut buf, i64::MIN), + Err(Errno::EINVAL) + ); } #[test] - fn test_dup3_with_cloexec() { + fn test_pwrite_einval_on_negative_offset() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // dup3 fd 0 to fd 5 with O_CLOEXEC - let result = sys_dup3(&mut proc, &mut host, 0, 5, O_CLOEXEC); - assert_eq!(result, Ok(5)); - let entry = proc.fd_table.get(5).unwrap(); - assert_eq!(entry.fd_flags, FD_CLOEXEC); + let fd = sys_open(&mut proc, &mut host, b"/tmp/f", O_RDWR | O_CREAT, 0o644).unwrap(); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"x", -1), + Err(Errno::EINVAL) + ); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"x", i64::MIN), + Err(Errno::EINVAL) + ); } #[test] - fn test_dup3_without_cloexec() { + fn test_pread_espipe_on_socket() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_dup3(&mut proc, &mut host, 0, 5, 0); - assert_eq!(result, Ok(5)); - let entry = proc.fd_table.get(5).unwrap(); - assert_eq!(entry.fd_flags, 0); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let mut buf = [0u8; 4]; + let result = sys_pread(&mut proc, &mut host, fd, &mut buf, 0); + assert_eq!(result, Err(Errno::ESPIPE)); } #[test] - fn test_dup3_same_fd_einval() { + fn test_time_returns_positive() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_dup3(&mut proc, &mut host, 0, 0, 0); - assert_eq!(result, Err(Errno::EINVAL)); + let t = sys_time(&mut proc, &mut host).unwrap(); + // MockHostIO returns (1234567890, 123456789) for clock_gettime + assert_eq!(t, 1234567890); } #[test] - fn test_pipe2_cloexec() { + fn test_gettimeofday() { let mut proc = Process::new(1); - let (r, w) = sys_pipe2(&mut proc, O_CLOEXEC).unwrap(); - let r_entry = proc.fd_table.get(r).unwrap(); - let w_entry = proc.fd_table.get(w).unwrap(); - assert_eq!(r_entry.fd_flags, FD_CLOEXEC); - assert_eq!(w_entry.fd_flags, FD_CLOEXEC); + let mut host = MockHostIO::new(); + let (sec, usec) = sys_gettimeofday(&mut proc, &mut host).unwrap(); + assert_eq!(sec, 1234567890); + assert_eq!(usec, 123456); // 123456789 nsec / 1000 = 123456 usec } #[test] - fn test_pipe2_nonblock() { + fn test_usleep() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (r, _w) = sys_pipe2(&mut proc, O_NONBLOCK).unwrap(); - // Verify O_NONBLOCK is set — read empty pipe should return EAGAIN - let mut buf = [0u8; 16]; - let result = sys_read(&mut proc, &mut host, r, &mut buf); - assert_eq!(result, Err(Errno::EAGAIN)); + // MockHostIO nanosleep is a no-op, so this should succeed + sys_usleep(&mut proc, &mut host, 1000).unwrap(); } #[test] - fn test_pipe2_both_flags() { + fn test_openat_at_fdcwd() { let mut proc = Process::new(1); - let (r, w) = sys_pipe2(&mut proc, O_CLOEXEC | O_NONBLOCK).unwrap(); - let r_entry = proc.fd_table.get(r).unwrap(); - let w_entry = proc.fd_table.get(w).unwrap(); - assert_eq!(r_entry.fd_flags, FD_CLOEXEC); - assert_eq!(w_entry.fd_flags, FD_CLOEXEC); - // Verify O_NONBLOCK is set on the OFDs - let r_ofd = proc.ofd_table.get(r_entry.ofd_ref.0).unwrap(); - let w_ofd = proc.ofd_table.get(w_entry.ofd_ref.0).unwrap(); - assert_ne!(r_ofd.status_flags & O_NONBLOCK, 0); - assert_ne!(w_ofd.status_flags & O_NONBLOCK, 0); + let mut host = MockHostIO::new(); + use wasm_posix_shared::flags::AT_FDCWD; + // AT_FDCWD should work like open() + let result = sys_openat(&mut proc, &mut host, AT_FDCWD, b"/tmp/test", O_RDONLY, 0); + // MockHostIO.host_open returns Ok(100), so we get a valid fd + assert!(result.is_ok()); } #[test] - fn test_writev_multiple_buffers() { + fn test_openat_absolute_path_ignores_dirfd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (r, w) = sys_pipe(&mut proc).unwrap(); - let bufs: &[&[u8]] = &[b"hello", b" ", b"world"]; - let n = sys_writev(&mut proc, &mut host, w, bufs).unwrap(); - assert_eq!(n, 11); - // Read back - let mut rbuf = [0u8; 32]; - let n2 = sys_read(&mut proc, &mut host, r, &mut rbuf).unwrap(); - assert_eq!(n2, 11); - assert_eq!(&rbuf[..11], b"hello world"); + // Absolute path should ignore dirfd + let result = sys_openat(&mut proc, &mut host, 999, b"/tmp/test", O_RDONLY, 0); + // Even with invalid dirfd, absolute path works + assert!(result.is_ok()); } #[test] - fn test_readv_multiple_buffers() { + fn test_openat_dirfd_enotdir() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (r, w) = sys_pipe(&mut proc).unwrap(); - // Write data to pipe - sys_write(&mut proc, &mut host, w, b"helloworld").unwrap(); - // Read into multiple buffers - let mut buf1 = [0u8; 5]; - let mut buf2 = [0u8; 5]; - let mut buffers: [&mut [u8]; 2] = [&mut buf1, &mut buf2]; - let n = sys_readv(&mut proc, &mut host, r, &mut buffers).unwrap(); - assert_eq!(n, 10); - assert_eq!(&buf1, b"hello"); - assert_eq!(&buf2, b"world"); + // fd 0 is stdin pipe by default, not Directory. + let result = sys_openat(&mut proc, &mut host, 0, b"relative", O_RDONLY, 0); + assert_eq!(result, Err(Errno::ENOTDIR)); } #[test] - fn test_writev_empty_buffer() { + fn test_fstatat_at_fdcwd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (_r, w) = sys_pipe(&mut proc).unwrap(); - let bufs: &[&[u8]] = &[b"", b"data", b""]; - let n = sys_writev(&mut proc, &mut host, w, bufs).unwrap(); - assert_eq!(n, 4); + let result = sys_fstatat(&mut proc, &mut host, AT_FDCWD, b"/tmp/test", 0); + assert!(result.is_ok()); } #[test] - fn test_writev_bad_fd() { + fn test_fstatat_absolute_path() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let bufs: &[&[u8]] = &[b"hello"]; - let result = sys_writev(&mut proc, &mut host, 99, bufs); - assert_eq!(result, Err(Errno::EBADF)); + // Absolute path ignores dirfd + let result = sys_fstatat(&mut proc, &mut host, 5, b"/tmp/test", 0); + assert!(result.is_ok()); } #[test] - fn test_readv_bad_fd() { + fn test_fstatat_symlink_nofollow() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mut buf1 = [0u8; 5]; - let mut buffers: [&mut [u8]; 1] = [&mut buf1]; - let result = sys_readv(&mut proc, &mut host, 99, &mut buffers); - assert_eq!(result, Err(Errno::EBADF)); + host.set_symlink(b"/tmp/link", b"/target"); + let result = sys_fstatat( + &mut proc, + &mut host, + AT_FDCWD, + b"/tmp/link", + AT_SYMLINK_NOFOLLOW, + ); + assert!(result.is_ok()); + let stat = result.unwrap(); + // MockHostIO lstat returns S_IFLNK + assert_eq!(stat.st_mode & S_IFLNK, S_IFLNK); } #[test] - fn test_readv_empty_buffers() { + fn test_fstatat_relative_invalid_dirfd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (r, w) = sys_pipe(&mut proc).unwrap(); - sys_write(&mut proc, &mut host, w, b"data").unwrap(); - let mut buf1 = [0u8; 0]; - let mut buf2 = [0u8; 4]; - let mut buffers: [&mut [u8]; 2] = [&mut buf1, &mut buf2]; - let n = sys_readv(&mut proc, &mut host, r, &mut buffers).unwrap(); - assert_eq!(n, 4); - assert_eq!(&buf2, b"data"); + // fd 5 doesn't exist, should get EBADF + let result = sys_fstatat(&mut proc, &mut host, 5, b"relative", 0); + assert!(matches!(result, Err(Errno::EBADF))); } #[test] - fn test_getrlimit_nofile_default() { - let proc = Process::new(1); - let (soft, hard) = sys_getrlimit(&proc, 7).unwrap(); // RLIMIT_NOFILE - assert_eq!(soft, 1024); - assert_eq!(hard, 4096); + fn test_unlinkat_at_fdcwd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_unlinkat(&mut proc, &mut host, AT_FDCWD, b"/tmp/test", 0); + assert!(result.is_ok()); } #[test] - fn test_getrlimit_stack_default() { - let proc = Process::new(1); - let (soft, hard) = sys_getrlimit(&proc, 3).unwrap(); // RLIMIT_STACK - assert_eq!(soft, 8 * 1024 * 1024); - assert_eq!(hard, u64::MAX); + fn test_unlinkat_removedir() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_unlinkat(&mut proc, &mut host, AT_FDCWD, b"/tmp/dir", AT_REMOVEDIR); + assert!(result.is_ok()); } #[test] - fn test_setrlimit_and_getrlimit() { + fn test_unlinkat_relative_invalid_dirfd() { let mut proc = Process::new(1); - sys_setrlimit(&mut proc, 7, 512, 2048).unwrap(); // RLIMIT_NOFILE - let (soft, hard) = sys_getrlimit(&proc, 7).unwrap(); - assert_eq!(soft, 512); - assert_eq!(hard, 2048); + let mut host = MockHostIO::new(); + // fd 5 doesn't exist, should get EBADF + let result = sys_unlinkat(&mut proc, &mut host, 5, b"relative", 0); + assert_eq!(result, Err(Errno::EBADF)); } #[test] - fn test_setrlimit_soft_exceeds_hard() { + fn test_mkdirat_at_fdcwd() { let mut proc = Process::new(1); - let result = sys_setrlimit(&mut proc, 7, 5000, 1000); - assert_eq!(result, Err(Errno::EINVAL)); + let mut host = MockHostIO::new(); + let result = sys_mkdirat(&mut proc, &mut host, AT_FDCWD, b"/tmp/newdir", 0o755); + assert!(result.is_ok()); } #[test] - fn test_setrlimit_nofile_enforced() { + fn test_mkdirat_relative_invalid_dirfd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - - // Lower RLIMIT_NOFILE soft limit to 5 - sys_setrlimit(&mut proc, 7, 5, 4096).unwrap(); - - // fds 0,1,2 are pre-opened (stdio). We can open 2 more (fd 3, 4). - let fd3 = sys_open(&mut proc, &mut host, b"/tmp/a", O_RDWR | O_CREAT, 0o644).unwrap(); - assert_eq!(fd3, 3); - let fd4 = sys_open(&mut proc, &mut host, b"/tmp/b", O_RDWR | O_CREAT, 0o644).unwrap(); - assert_eq!(fd4, 4); - - // fd 5 should fail with EMFILE - let result = sys_open(&mut proc, &mut host, b"/tmp/c", O_RDWR | O_CREAT, 0o644); - assert_eq!(result, Err(Errno::EMFILE)); + // fd 5 doesn't exist, should get EBADF + let result = sys_mkdirat(&mut proc, &mut host, 5, b"relative", 0o755); + assert_eq!(result, Err(Errno::EBADF)); } #[test] - fn test_rlimit_fsize_enforced() { + fn test_renameat_at_fdcwd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - - // Open a file for writing - let fd = sys_open( + let result = sys_renameat( &mut proc, &mut host, - b"/tmp/fsize_test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - - // Set RLIMIT_FSIZE to 10 bytes - sys_setrlimit(&mut proc, 1, 10, 10).unwrap(); // resource 1 = RLIMIT_FSIZE - - // Writing 5 bytes at offset 0 should succeed (end_pos=5 <= 10) - let result = sys_write(&mut proc, &mut host, fd, &[1, 2, 3, 4, 5]); + AT_FDCWD, + b"/tmp/old", + AT_FDCWD, + b"/tmp/new", + ); assert!(result.is_ok()); - - // Writing 10 more bytes should fail (end_pos=15 > 10) with EFBIG - let result = sys_write(&mut proc, &mut host, fd, &[0u8; 10]); - assert_eq!(result, Err(Errno::EFBIG)); - - // SIGXFSZ should have been raised - assert_ne!(proc.signals.deliverable(), 0); } #[test] - fn test_ftruncate_rlimit_fsize() { + fn test_renameat_absolute_paths() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/ftrunc_test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - - // Set RLIMIT_FSIZE to 100 bytes - sys_setrlimit(&mut proc, 1, 100, 100).unwrap(); - - // Truncate to 50 should succeed - assert!(sys_ftruncate(&mut proc, &mut host, fd, 50).is_ok()); - - // Truncate to 200 should fail with EFBIG - let result = sys_ftruncate(&mut proc, &mut host, fd, 200); - assert_eq!(result, Err(Errno::EFBIG)); + // Both paths absolute, dirfds are ignored + let result = sys_renameat(&mut proc, &mut host, 5, b"/tmp/old", 6, b"/tmp/new"); + assert!(result.is_ok()); } #[test] - fn test_poll_socket_pollerr() { - use wasm_posix_shared::poll::*; - use wasm_posix_shared::socket::*; - + fn test_renameat_relative_invalid_dirfd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - - // Shutdown both directions to trigger POLLERR - sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); - - let mut fds = [WasmPollFd { - fd: fd0, - events: POLLIN | POLLOUT, - revents: 0, - }]; - let result = sys_poll(&mut proc, &mut host, &mut fds, 0).unwrap(); - assert!(result > 0); - assert_ne!(fds[0].revents & POLLERR, 0); + // Old path relative with non-existent dirfd + let result = sys_renameat(&mut proc, &mut host, 5, b"relative", AT_FDCWD, b"/tmp/new"); + assert_eq!(result, Err(Errno::EBADF)); } #[test] - fn test_getrlimit_invalid_resource() { - let proc = Process::new(1); - let result = sys_getrlimit(&proc, 99); - assert_eq!(result, Err(Errno::EINVAL)); + fn test_tcgetattr_returns_terminal_state() { + let mut proc = terminal_process(1); + let mut buf = [0u8; 60]; + let result = sys_tcgetattr(&mut proc, 0, &mut buf); + assert!(result.is_ok()); + // Verify c_lflag has ECHO set (in the 4th u32, bytes 12-15) + let c_lflag = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]); + assert!(c_lflag & 0o0010 != 0); // ECHO } - // ---- truncate tests ---- - #[test] - fn test_truncate_path() { + fn test_tcgetattr_enotty_for_regular_file() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_truncate(&mut proc, &mut host, b"/tmp/test", 100); - assert!(result.is_ok()); + let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY, 0).unwrap(); + let mut buf = [0u8; 60]; + let result = sys_tcgetattr(&mut proc, fd, &mut buf); + assert_eq!(result, Err(Errno::ENOTTY)); } - // ---- fdatasync tests ---- + #[test] + fn test_tcsetattr_modifies_terminal_state() { + let mut proc = terminal_process(1); + let mut buf = [0u8; 60]; + sys_tcgetattr(&mut proc, 0, &mut buf).unwrap(); + // Clear ECHO in c_lflag (4th u32, bytes 12-15) + let c_lflag = u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]); + let new_lflag = c_lflag & !0o0010; // Clear ECHO + buf[12..16].copy_from_slice(&new_lflag.to_le_bytes()); + // Set attrs with TCSANOW=0 + let result = sys_tcsetattr(&mut proc, 0, 0, &buf); + assert!(result.is_ok()); + // Read back + let mut buf2 = [0u8; 60]; + sys_tcgetattr(&mut proc, 0, &mut buf2).unwrap(); + let c_lflag2 = u32::from_le_bytes([buf2[12], buf2[13], buf2[14], buf2[15]]); + assert_eq!(c_lflag2 & 0o0010, 0); // ECHO cleared + } #[test] - fn test_fdatasync() { - let mut proc = Process::new(1); + fn test_ioctl_tiocgwinsz() { + let mut proc = terminal_process(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - let result = sys_fdatasync(&mut proc, &mut host, fd); + let mut buf = [0u8; 8]; + let result = sys_ioctl(&mut proc, &mut host, 0, 0x5413, &mut buf); // TIOCGWINSZ assert!(result.is_ok()); + let ws_row = u16::from_le_bytes([buf[0], buf[1]]); + let ws_col = u16::from_le_bytes([buf[2], buf[3]]); + assert_eq!(ws_row, 24); + assert_eq!(ws_col, 80); } - // ---- fchmod tests ---- - #[test] - fn test_fchmod() { - let mut proc = Process::new(1); + fn test_ioctl_tiocswinsz() { + let mut proc = terminal_process(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - let result = sys_fchmod(&mut proc, &mut host, fd, 0o755); + let mut buf = [0u8; 8]; + buf[0..2].copy_from_slice(&120u16.to_le_bytes()); // rows + buf[2..4].copy_from_slice(&200u16.to_le_bytes()); // cols + let result = sys_ioctl(&mut proc, &mut host, 0, 0x5414, &mut buf); // TIOCSWINSZ assert!(result.is_ok()); + // Read back + let mut buf2 = [0u8; 8]; + sys_ioctl(&mut proc, &mut host, 0, 0x5413, &mut buf2).unwrap(); // TIOCGWINSZ + let ws_row = u16::from_le_bytes([buf2[0], buf2[1]]); + let ws_col = u16::from_le_bytes([buf2[2], buf2[3]]); + assert_eq!(ws_row, 120); + assert_eq!(ws_col, 200); } #[test] - fn test_fchmod_pipe_accepted() { - // Linux accepts fchmod on non-regular fds (pipes, sockets, devices) - // even though the mode change has no observable effect. dinit and - // other daemons rely on this — pre-relaxation EINVAL aborted them - // mid-startup. + fn test_ioctl_unsupported_returns_enotty() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let (r, _w) = sys_pipe(&mut proc).unwrap(); - let result = sys_fchmod(&mut proc, &mut host, r, 0o755); - assert_eq!(result, Ok(())); + let mut buf = [0u8; 8]; + let result = sys_ioctl(&mut proc, &mut host, 0, 0x9999, &mut buf); + assert_eq!(result, Err(Errno::ENOTTY)); } - // ---- fchown tests ---- - #[test] - fn test_fchown() { + fn test_ioctl_fionbio_set_nonblock() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open( - &mut proc, - &mut host, - b"/tmp/test", - O_WRONLY | O_CREAT, - 0o644, - ) - .unwrap(); - let result = sys_fchown(&mut proc, &mut host, fd, 1000, 1000); + // Set O_NONBLOCK via FIONBIO on stdout (fd 1) + let mut buf = 1i32.to_le_bytes(); + let result = sys_ioctl(&mut proc, &mut host, 1, 0x5421, &mut buf); assert!(result.is_ok()); + let ofd = proc + .ofd_table + .get(proc.fd_table.get(1).unwrap().ofd_ref.0) + .unwrap(); + assert_ne!(ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK, 0); + // Clear it + let mut buf = 0i32.to_le_bytes(); + sys_ioctl(&mut proc, &mut host, 1, 0x5421, &mut buf).unwrap(); + let ofd = proc + .ofd_table + .get(proc.fd_table.get(1).unwrap().ofd_ref.0) + .unwrap(); + assert_eq!(ofd.status_flags & wasm_posix_shared::flags::O_NONBLOCK, 0); } - // ----------------------------------------------------------------------- - // Process group and session tests - // ----------------------------------------------------------------------- - #[test] - fn test_getpgrp_default() { - let proc = Process::new(1); - assert_eq!(sys_getpgrp(&proc), 1); // pgid == pid + fn test_ioctl_fioclex_fionclex() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 4]; + // Set FD_CLOEXEC via FIOCLEX on fd 0 + sys_ioctl(&mut proc, &mut host, 0, 0x5451, &mut buf).unwrap(); + assert_ne!( + proc.fd_table.get(0).unwrap().fd_flags & wasm_posix_shared::fd_flags::FD_CLOEXEC, + 0 + ); + // Clear via FIONCLEX + sys_ioctl(&mut proc, &mut host, 0, 0x5450, &mut buf).unwrap(); + assert_eq!( + proc.fd_table.get(0).unwrap().fd_flags & wasm_posix_shared::fd_flags::FD_CLOEXEC, + 0 + ); } #[test] - fn test_getpgid_self() { - let proc = Process::new(1); - assert_eq!(sys_getpgid(&proc, 0), Ok(1)); // pid=0 means current process - assert_eq!(sys_getpgid(&proc, 1), Ok(1)); // pid=self works too + fn test_ioctl_fionread_pipe() { + let mut proc = Process::new(1); + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let mut host = MockHostIO::new(); + // Write some data + sys_write(&mut proc, &mut host, write_fd, b"hello").unwrap(); + // FIONREAD should return 5 + let mut buf = [0u8; 4]; + sys_ioctl(&mut proc, &mut host,read_fd, 0x541B, &mut buf).unwrap(); + let avail = i32::from_le_bytes(buf); + assert_eq!(avail, 5); } #[test] - fn test_getpgid_other_esrch() { - let proc = Process::new(1); - assert_eq!(sys_getpgid(&proc, 999), Err(Errno::ESRCH)); + fn test_ioctl_fionread_regular() { + let mut proc = terminal_process(1); + let mut host = MockHostIO::new(); + // FIONREAD on a CharDevice returns 0 + let mut buf = [0u8; 4]; + sys_ioctl(&mut proc, &mut host, 0, 0x541B, &mut buf).unwrap(); + let avail = i32::from_le_bytes(buf); + assert_eq!(avail, 0); } - #[test] - fn test_setpgid_self() { - let mut proc = Process::new(2); - proc.sid = 1; // Not a session leader (child of pid 1) - let result = sys_setpgid(&mut proc, 0, 42); // pid=0 means self - assert!(result.is_ok()); - assert_eq!(proc.pgid, 42); - } - - #[test] - fn test_setpgid_zero_pgid() { - let mut proc = Process::new(2); - proc.sid = 1; // Not a session leader - let result = sys_setpgid(&mut proc, 0, 0); // pgid=0 means use pid - assert!(result.is_ok()); - assert_eq!(proc.pgid, 2); // pgid set to pid - } + // ---- MSG_OOB / SIOCATMARK tests ---- #[test] - fn test_setpgid_session_leader_eperm() { + fn test_send_recv_msg_oob() { let mut proc = Process::new(1); - // Make it a session leader by calling setsid - let _ = sys_setsid(&mut proc); - assert_eq!(proc.sid, proc.pid); // now a session leader - let result = sys_setpgid(&mut proc, 0, 42); - assert_eq!(result, Err(Errno::EPERM)); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + let (fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + // No OOB pending: SIOCATMARK returns 0 + let mut iobuf = [0u8; 4]; + sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); + assert_eq!(i32::from_le_bytes(iobuf), 0); + + // Send OOB byte from fd0 + let n = sys_send(&mut proc, &mut host, fd0, b"X", MSG_OOB).unwrap(); + assert_eq!(n, 1); + + // SIOCATMARK on fd1 returns 1 + let mut iobuf = [0u8; 4]; + sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); + assert_eq!(i32::from_le_bytes(iobuf), 1); + + // Recv OOB byte from fd1 + let mut buf = [0u8; 1]; + let n = sys_recv(&mut proc, &mut host, fd1, &mut buf, MSG_OOB).unwrap(); + assert_eq!(n, 1); + assert_eq!(buf[0], b'X'); + + // After reading OOB, SIOCATMARK returns 0 + let mut iobuf = [0u8; 4]; + sys_ioctl(&mut proc, &mut host,fd1, 0x8905, &mut iobuf).unwrap(); + assert_eq!(i32::from_le_bytes(iobuf), 0); } #[test] - fn test_setpgid_other_process_esrch() { + fn test_recv_msg_oob_no_data() { let mut proc = Process::new(1); - let result = sys_setpgid(&mut proc, 999, 42); // pid != self - assert_eq!(result, Err(Errno::ESRCH)); - } + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; - #[test] - fn test_getsid_self() { - let proc = Process::new(1); - assert_eq!(sys_getsid(&proc, 0), Ok(0)); // initial process has sid=0 (not a session leader) - } + let (_fd0, fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - #[test] - fn test_getsid_other_esrch() { - let proc = Process::new(1); - assert_eq!(sys_getsid(&proc, 999), Err(Errno::ESRCH)); + // Recv OOB with no OOB pending returns EINVAL + let mut buf = [0u8; 1]; + let err = sys_recv(&mut proc, &mut host, fd1, &mut buf, MSG_OOB).unwrap_err(); + assert_eq!(err, Errno::EINVAL); } #[test] - fn test_setsid() { - // Simulate a child process (pid=2) that inherited parent's session (sid=1) - let mut proc = Process::new(2); - proc.sid = 1; - proc.pgid = 1; - let result = sys_setsid(&mut proc); - assert_eq!(result, Ok(2)); // Returns new session id == pid - assert_eq!(proc.sid, 2); - assert_eq!(proc.pgid, 2); + fn test_send_msg_oob_closed_peer_cannot_target_reused_socket_slot() { + let mut proc = Process::new(9040); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + let (sender_fd, peer_fd) = + sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let peer_entry = proc.fd_table.get(peer_fd).unwrap(); + let peer_ofd = proc.ofd_table.get(peer_entry.ofd_ref.0).unwrap(); + let peer_idx = (-(peer_ofd.host_handle + 1)) as usize; + sys_close(&mut proc, &mut host, peer_fd).unwrap(); + + let replacement_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let replacement_entry = proc.fd_table.get(replacement_fd).unwrap(); + let replacement_ofd = proc.ofd_table.get(replacement_entry.ofd_ref.0).unwrap(); + let replacement_idx = (-(replacement_ofd.host_handle + 1)) as usize; + assert_eq!(replacement_idx, peer_idx); + + assert_eq!( + sys_send(&mut proc, &mut host, sender_fd, b"X", MSG_OOB).unwrap_err(), + Errno::EPIPE, + ); + assert!(proc + .signals + .is_pending(wasm_posix_shared::signal::SIGPIPE)); + assert!(proc.sockets.get(replacement_idx).unwrap().oob_byte.is_none()); } + // ---- prctl tests ---- + #[test] - fn test_setsid_already_leader_fails() { + fn test_prctl_set_get_name() { let mut proc = Process::new(1); - // First call setsid to become a session leader - let result = sys_setsid(&mut proc); - assert_eq!(result, Ok(1)); - assert_eq!(proc.sid, 1); - // Second call should fail — already a session leader - let result = sys_setsid(&mut proc); - assert_eq!(result, Err(Errno::EPERM)); + let mut buf = [0u8; 16]; + buf[..5].copy_from_slice(b"hello"); + sys_prctl(&mut proc, 15, 0, &mut buf).unwrap(); // PR_SET_NAME + let mut out = [0u8; 16]; + sys_prctl(&mut proc, 16, 0, &mut out).unwrap(); // PR_GET_NAME + assert_eq!(&out[..5], b"hello"); + assert_eq!(out[5], 0); } - // ---- Phase 12: Remaining *at() variants ---- - #[test] - fn test_faccessat_at_fdcwd() { + fn test_prctl_unknown_is_noop() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // Should delegate to sys_access - let result = sys_faccessat(&mut proc, &mut host, -100, b"/tmp/test", 0, 0); - assert!(result.is_ok()); + let mut buf = [0u8; 16]; + assert!(sys_prctl(&mut proc, 999, 0, &mut buf).is_ok()); } #[test] - fn test_faccessat_real_dirfd_invalid() { + fn test_fcntl_f_getown_default_zero() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_faccessat(&mut proc, &mut host, 5, b"relative", 0, 0); - assert_eq!(result, Err(Errno::EBADF)); + let result = sys_fcntl(&mut proc, 0, 9, 0); // F_GETOWN + assert_eq!(result, Ok(0)); } #[test] - fn test_faccessat_absolute_path() { + fn test_fcntl_f_setown_and_getown() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_faccessat(&mut proc, &mut host, 5, b"/absolute", 0, 0); - assert!(result.is_ok()); + // Set owner to pid 42 + let result = sys_fcntl(&mut proc, 0, 8, 42); // F_SETOWN + assert_eq!(result, Ok(0)); + // Get owner + let result = sys_fcntl(&mut proc, 0, 9, 0); // F_GETOWN + assert_eq!(result, Ok(42)); } #[test] - fn test_fchmodat_at_fdcwd() { + fn test_open_nofollow_passed_through() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_fchmodat(&mut proc, &mut host, -100, b"/tmp/test", 0o644, 0); + use wasm_posix_shared::flags::O_NOFOLLOW; + // Open with O_NOFOLLOW should work for regular files + let result = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY | O_NOFOLLOW, 0); assert!(result.is_ok()); + // Verify O_NOFOLLOW is NOT stored in status flags + let fd = result.unwrap(); + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!(ofd.status_flags & O_NOFOLLOW, 0); // Not in status flags } #[test] - fn test_fchmodat_real_dirfd_invalid() { + fn test_pipe_read_nonblock_eagain() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_fchmodat(&mut proc, &mut host, 5, b"relative", 0o644, 0); - assert_eq!(result, Err(Errno::EBADF)); + let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); + // Set read end to non-blocking + sys_fcntl(&mut proc, read_fd, F_SETFL, O_NONBLOCK).unwrap(); + // Read with nothing in pipe — should get EAGAIN + let mut buf = [0u8; 16]; + let result = sys_read(&mut proc, &mut host, read_fd, &mut buf); + assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_fchownat_at_fdcwd() { + fn test_pipe_read_nonblock_with_data() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_fchownat(&mut proc, &mut host, -100, b"/tmp/test", 1000, 1000, 0); - assert!(result.is_ok()); + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + sys_fcntl(&mut proc, read_fd, F_SETFL, O_NONBLOCK).unwrap(); + // Write some data + sys_write(&mut proc, &mut host, write_fd, b"hello").unwrap(); + // Read — should succeed + let mut buf = [0u8; 16]; + let n = sys_read(&mut proc, &mut host, read_fd, &mut buf).unwrap(); + assert_eq!(n, 5); + assert_eq!(&buf[..5], b"hello"); } #[test] - fn test_fchownat_real_dirfd_invalid() { + fn test_pipe_read_eof_when_write_end_closed() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_fchownat(&mut proc, &mut host, 5, b"relative", 1000, 1000, 0); - assert_eq!(result, Err(Errno::EBADF)); + let (read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + sys_fcntl(&mut proc, read_fd, F_SETFL, O_NONBLOCK).unwrap(); + // Close write end + sys_close(&mut proc, &mut host, write_fd).unwrap(); + // Read — should get 0 (EOF), not EAGAIN + let mut buf = [0u8; 16]; + let n = sys_read(&mut proc, &mut host, read_fd, &mut buf).unwrap(); + assert_eq!(n, 0); } #[test] - fn test_linkat_both_fdcwd() { + fn test_pipe_write_nonblock_eagain_when_full() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_linkat(&mut proc, &mut host, -100, b"/old", -100, b"/new", 0); - assert!(result.is_ok()); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + sys_fcntl(&mut proc, write_fd, F_SETFL, O_NONBLOCK).unwrap(); + // Fill pipe buffer (64KB) + let big = [0u8; 65536]; + let n = sys_write(&mut proc, &mut host, write_fd, &big).unwrap(); + assert_eq!(n, 65536); + // Next write should get EAGAIN + let result = sys_write(&mut proc, &mut host, write_fd, b"x"); + assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_linkat_real_dirfd_invalid() { + fn test_blocking_pipe_read_returns_eagain_for_host_retry() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_linkat(&mut proc, &mut host, 5, b"old", -100, b"/new", 0); - assert_eq!(result, Err(Errno::EBADF)); + let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); + + let mut buf = [0u8; 16]; + let result = sys_read(&mut proc, &mut host, read_fd, &mut buf); + assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_symlinkat_at_fdcwd() { + fn test_blocking_pipe_write_returns_eagain_for_host_retry() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_symlinkat(&mut proc, &mut host, b"target", -100, b"/link"); - assert!(result.is_ok()); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + // Fill the pipe buffer + let big = [0u8; 65536]; + let n = sys_write(&mut proc, &mut host, write_fd, &big).unwrap(); + assert_eq!(n, 65536); + + let result = sys_write(&mut proc, &mut host, write_fd, b"x"); + assert_eq!(result, Err(Errno::EAGAIN)); } + // ---- umask tests ---- + #[test] - fn test_symlinkat_real_dirfd_invalid() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // fd 5 doesn't exist, should get EBADF - let result = sys_symlinkat(&mut proc, &mut host, b"target", 5, b"link"); - assert_eq!(result, Err(Errno::EBADF)); + fn test_umask_default() { + let proc = Process::new(1); + assert_eq!(proc.umask, 0o022); } #[test] - fn test_readlinkat_at_fdcwd() { + fn test_umask_set_and_get_old() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - let result = sys_readlinkat(&mut proc, &mut host, -100, b"/some/link", &mut buf); - // MockHostIO host_readlink returns placeholder data - assert!(result.is_ok()); + let old = sys_umask(&mut proc, 0o077); + assert_eq!(old, 0o022); // previous default + let old2 = sys_umask(&mut proc, 0o000); + assert_eq!(old2, 0o077); } #[test] - fn test_readlinkat_real_dirfd_invalid() { + fn test_umask_masks_high_bits() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - // fd 5 doesn't exist, should get EBADF - let result = sys_readlinkat(&mut proc, &mut host, 5, b"relative/link", &mut buf); - assert_eq!(result, Err(Errno::EBADF)); + let old = sys_umask(&mut proc, 0o7777); // only 0o777 stored + assert_eq!(old, 0o022); + assert_eq!(proc.umask, 0o777); } - // ---- Phase 12: select() ---- + // ---- uname tests ---- #[test] - fn test_select_regular_file_readable() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // Open a regular file - let fd = sys_open(&mut proc, &mut host, b"/tmp/test", 0, 0o644).unwrap(); - assert_eq!(fd, 3); + fn test_uname_returns_fields() { + let mut buf = [0u8; 325]; + let result = sys_uname(&mut buf); + assert!(result.is_ok()); + // sysname at offset 0 + assert_eq!(&buf[0..10], b"wasm-posix"); + assert_eq!(buf[10], 0); // null terminated + // nodename at offset 65 + assert_eq!(&buf[65..74], b"localhost"); + // machine at offset 260 + assert_eq!(&buf[260..266], b"wasm32"); + } - // Set fd 3 in readfds - let mut readfds = [0u8; 128]; - readfds[0] = 0b1000; // bit 3 - let result = sys_select(&mut proc, &mut host, 4, Some(&mut readfds), None, None, 0); - assert_eq!(result, Ok(1)); - assert_eq!(readfds[0] & 0b1000, 0b1000); // fd 3 still set + #[test] + fn test_uname_buffer_too_small() { + let mut buf = [0u8; 100]; + let result = sys_uname(&mut buf); + assert_eq!(result, Err(Errno::EINVAL)); } + // ---- sysconf tests ---- + #[test] - fn test_select_empty_sets() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_select(&mut proc, &mut host, 0, None, None, None, 0); - assert_eq!(result, Ok(0)); + fn test_sysconf_page_size() { + assert_eq!(sys_sysconf(30), Ok(65536)); // _SC_PAGE_SIZE + assert_eq!(sys_sysconf(11), Ok(65536)); // _SC_PAGESIZE } #[test] - fn test_select_invalid_nfds() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_select(&mut proc, &mut host, -1, None, None, None, 0); - assert_eq!(result, Err(Errno::EINVAL)); + fn test_sysconf_open_max() { + assert_eq!(sys_sysconf(4), Ok(1024)); // _SC_OPEN_MAX } #[test] - fn test_select_pipe_readable() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); + fn test_sysconf_arg_max_matches_exec_metadata_boundary() { + assert_eq!(sys_sysconf(0), Ok(4 * 1024 * 1024)); // _SC_ARG_MAX + } - let (rfd, wfd) = sys_pipe(&mut proc).unwrap(); - // Write data to pipe - sys_write(&mut proc, &mut host, wfd, b"hello").unwrap(); + #[test] + fn test_sysconf_nprocessors() { + assert_eq!(sys_sysconf(6), Ok(1)); // _SC_NPROCESSORS_ONLN + } - let mut readfds = [0u8; 128]; - let byte = rfd as usize / 8; - let bit = rfd as usize % 8; - readfds[byte] = 1 << bit; + #[test] + fn test_sysconf_invalid() { + assert_eq!(sys_sysconf(9999), Err(Errno::EINVAL)); + } - let result = sys_select( - &mut proc, - &mut host, - rfd + 1, - Some(&mut readfds), - None, - None, - 0, - ); - assert_eq!(result, Ok(1)); - assert_ne!(readfds[byte] & (1 << bit), 0); + // ---- pathconf/fpathconf tests ---- + + fn namespace_boundary_path(final_component_len: usize, final_is_dir: bool) -> Vec { + let mut path = vec![b'/']; + for index in 0..15 { + if index > 0 { + path.push(b'/'); + } + path.extend(std::iter::repeat_n(b'd', 252)); + path.extend_from_slice(b"dir"); + } + path.push(b'/'); + if final_is_dir { + path.extend(std::iter::repeat_n(b'd', final_component_len - 3)); + path.extend_from_slice(b"dir"); + } else { + path.extend(std::iter::repeat_n(b'f', final_component_len)); + } + path } #[test] - fn test_select_pipe_writable() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); + fn test_pathconf_delegates_resolved_path_and_value() { + use wasm_posix_shared::pathconf as pc; - let (_rfd, wfd) = sys_pipe(&mut proc).unwrap(); - - let mut writefds = [0u8; 128]; - let byte = wfd as usize / 8; - let bit = wfd as usize % 8; - writefds[byte] = 1 << bit; + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(123)); - let result = sys_select( - &mut proc, - &mut host, - wfd + 1, - None, - Some(&mut writefds), - None, - 0, + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::NAME_MAX), + Ok(Some(123)) ); - assert_eq!(result, Ok(1)); - assert_ne!(writefds[byte] & (1 << bit), 0); + assert_eq!(host.pathconf_calls, vec![(b"/tmp/foo".to_vec(), pc::NAME_MAX)]); } #[test] - fn test_select_multiple_fds() { - let mut proc = Process::new(1); + fn test_pathconf_preserves_indeterminate_result() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); let mut host = MockHostIO::new(); + host.pathconf_result = Ok(None); - let fd1 = sys_open(&mut proc, &mut host, b"/file1", 0, 0o644).unwrap(); - let fd2 = sys_open(&mut proc, &mut host, b"/file2", 0, 0o644).unwrap(); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::LINK_MAX), + Ok(None) + ); + } - let mut readfds = [0u8; 128]; - readfds[fd1 as usize / 8] |= 1 << (fd1 as usize % 8); - readfds[fd2 as usize / 8] |= 1 << (fd2 as usize % 8); + #[test] + fn test_pathconf_async_io_is_supported_for_regular_files_only() { + use wasm_posix_shared::pathconf as pc; - let max_fd = core::cmp::max(fd1, fd2) + 1; - let result = sys_select( - &mut proc, - &mut host, - max_fd, - Some(&mut readfds), - None, - None, - 0, + let proc = Process::new(1); + let mut host = MockHostIO::new(); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp/foo", pc::ASYNC_IO), + Ok(Some(1)) ); - assert_eq!(result, Ok(2)); - } + assert!(host.pathconf_calls.is_empty()); - // ---- Phase 12: setuid/setgid/seteuid/setegid ---- + host.pathconf_result = Err(Errno::EINVAL); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/tmp", pc::ASYNC_IO), + Err(Errno::EINVAL) + ); + } #[test] - fn test_setuid_as_root_sets_both() { - let mut proc = Process::new(1); - assert_eq!(proc.uid, 0); - sys_setuid(&mut proc, 42).unwrap(); - assert_eq!(proc.uid, 42); - assert_eq!(proc.euid, 42); + fn test_pathconf_validates_name_before_path() { + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_missing_path(b"/missing"); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/missing", 999), + Err(Errno::EINVAL) + ); + assert!(host.pathconf_calls.is_empty()); } #[test] - fn test_setuid_nonroot_to_own_uid_sets_euid_only() { - let mut proc = Process::new(1); - sys_setuid(&mut proc, 7).unwrap(); // drop to uid=euid=7 - // Simulate regaining privilege partly: impossible without saved-set, - // but setting euid back to real uid is always allowed. - sys_seteuid(&mut proc, 7).unwrap(); - assert_eq!(proc.euid, 7); + fn test_pathconf_reports_missing_path() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_missing_path(b"/missing"); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/missing", pc::PATH_MAX), + Err(Errno::ENOENT) + ); + assert!(host.pathconf_calls.is_empty()); } #[test] - fn test_setuid_nonroot_to_other_uid_fails() { - let mut proc = Process::new(1); - sys_setuid(&mut proc, 7).unwrap(); - assert_eq!(sys_setuid(&mut proc, 99), Err(Errno::EPERM)); - assert_eq!(proc.uid, 7); - assert_eq!(proc.euid, 7); + fn test_namespace_component_limit_is_enforced_in_bytes() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut accepted = vec![b'/']; + accepted.extend(std::iter::repeat_n(b'a', 255)); + assert_eq!( + sys_pathconf(&proc, &mut host, &accepted, pc::NAME_MAX), + Ok(Some(255)) + ); + + let mut rejected = vec![b'/']; + rejected.extend(std::iter::repeat_n(b'a', 256)); + assert_eq!( + sys_pathconf(&proc, &mut host, &rejected, pc::NAME_MAX), + Err(Errno::ENAMETOOLONG) + ); + + let mut utf8 = vec![b'/']; + for _ in 0..128 { + utf8.extend_from_slice("é".as_bytes()); + } + assert_eq!( + sys_pathconf(&proc, &mut host, &utf8, pc::NAME_MAX), + Err(Errno::ENAMETOOLONG) + ); } #[test] - fn test_setgid_as_root_sets_both() { - let mut proc = Process::new(1); - sys_setgid(&mut proc, 42).unwrap(); - assert_eq!(proc.gid, 42); - assert_eq!(proc.egid, 42); + fn test_namespace_path_limit_includes_terminating_nul() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(NAMESPACE_PATH_MAX as i64)); + let accepted = namespace_boundary_path(254, false); + assert_eq!(accepted.len(), 4095); + assert_eq!( + sys_pathconf(&proc, &mut host, &accepted, pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + + let rejected = namespace_boundary_path(255, false); + assert_eq!(rejected.len(), 4096); + assert_eq!( + sys_pathconf(&proc, &mut host, &rejected, pc::PATH_MAX), + Err(Errno::ENAMETOOLONG) + ); } #[test] - fn test_setgid_nonroot_to_other_gid_fails() { - let mut proc = Process::new(1); - sys_setuid(&mut proc, 7).unwrap(); // drop privilege - assert_eq!(sys_setgid(&mut proc, 99), Err(Errno::EPERM)); + fn test_namespace_rejects_symlink_expansion_past_path_max() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + let long_directory = namespace_boundary_path(254, true); + assert_eq!(long_directory.len(), 4095); + host.set_symlink(b"/link", &long_directory); + assert_eq!( + sys_pathconf(&proc, &mut host, b"/link/child", pc::PATH_MAX), + Err(Errno::ENAMETOOLONG) + ); } #[test] - fn test_seteuid_as_root_allows_any() { + fn test_namespace_allows_short_relative_path_from_deep_cwd() { + use wasm_posix_shared::pathconf as pc; + let mut proc = Process::new(1); - sys_seteuid(&mut proc, 500).unwrap(); - assert_eq!(proc.uid, 0); // real uid unchanged - assert_eq!(proc.euid, 500); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(NAMESPACE_PATH_MAX as i64)); + proc.cwd = namespace_boundary_path(254, true); + assert_eq!(proc.cwd.len(), 4095); + + assert_eq!( + sys_pathconf(&proc, &mut host, b".", pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + let mut child = proc.cwd.clone(); + child.extend_from_slice(b"/child"); + assert_eq!( + sys_pathconf(&proc, &mut host, b"child", pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + assert_eq!( + host.pathconf_calls, + vec![ + (proc.cwd.clone(), pc::PATH_MAX), + (child, pc::PATH_MAX), + ] + ); } #[test] - fn test_seteuid_nonroot_back_to_ruid() { + fn test_fpathconf_uses_live_host_handle_after_path_disappears() { + use wasm_posix_shared::pathconf as pc; + let mut proc = Process::new(1); - sys_setuid(&mut proc, 7).unwrap(); - sys_seteuid(&mut proc, 7).unwrap(); - assert_eq!(proc.euid, 7); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/tmp/foo", O_RDONLY, 0).unwrap(); + host.set_missing_path(b"/tmp/foo"); + host.fpathconf_result = Ok(Some(4096)); + + assert_eq!( + sys_fpathconf(&proc, &mut host, fd, pc::PATH_MAX), + Ok(Some(4096)) + ); + assert_eq!(host.fpathconf_calls, vec![(100, pc::PATH_MAX)]); + assert!(host.pathconf_calls.is_empty()); } #[test] - fn test_seteuid_nonroot_to_other_fails() { - let mut proc = Process::new(1); - sys_setuid(&mut proc, 7).unwrap(); - assert_eq!(sys_seteuid(&mut proc, 99), Err(Errno::EPERM)); + fn test_fpathconf_invalid_fd_does_not_call_host() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, 99, pc::PATH_MAX), + Err(Errno::EBADF) + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_setegid_as_root_allows_any() { + fn test_fpathconf_kernel_pipe_reports_pipe_buf_without_host() { + use wasm_posix_shared::pathconf as pc; + let mut proc = Process::new(1); - sys_setegid(&mut proc, 500).unwrap(); - assert_eq!(proc.gid, 0); - assert_eq!(proc.egid, 500); + let mut host = MockHostIO::new(); + let (read_fd, _) = sys_pipe(&mut proc).unwrap(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, read_fd, pc::PIPE_BUF), + Ok(Some(crate::pipe::PIPE_BUF as i64)) + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_can_signal_root_to_anyone() { - assert!(can_signal(0, 0, 1, 1)); - assert!(can_signal(0, 0, 42, 99)); + fn test_fpathconf_host_pipe_leaves_pipe_buf_indeterminate() { + use wasm_posix_shared::pathconf as pc; + + let proc = Process::new(1); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::PIPE_BUF), + Ok(None) + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_can_signal_same_user() { - assert!(can_signal(7, 7, 7, 7)); - } + fn test_fpathconf_async_io_regular_and_memfd_support() { + use wasm_posix_shared::pathconf as pc; - #[test] - fn test_can_signal_matches_real_or_effective() { - // sender.euid matches target.ruid - assert!(can_signal(99, 7, 7, 0)); - // sender.ruid matches target.euid - assert!(can_signal(7, 99, 0, 7)); - } + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.fpathconf_result = Ok(Some(1)); + let regular = sys_open(&mut proc, &mut host, b"/tmp/foo", O_RDONLY, 0).unwrap(); + let memfd = sys_memfd_create(&mut proc, b"pathconf", 0).unwrap(); + let (pipe, _) = sys_pipe(&mut proc).unwrap(); - #[test] - fn test_can_signal_denies_cross_user() { - assert!(!can_signal(7, 7, 0, 0)); - assert!(!can_signal(1, 1, 2, 2)); + assert_eq!( + sys_fpathconf(&proc, &mut host, regular, pc::ASYNC_IO), + Ok(Some(1)) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, memfd, pc::ASYNC_IO), + Ok(Some(1)) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, pipe, pc::ASYNC_IO), + Err(Errno::EINVAL) + ); + assert_eq!(host.fpathconf_calls, vec![(100, pc::ASYNC_IO)]); } #[test] - fn test_can_query_sched_root_allowed() { - assert!(can_query_sched(0, 1, 1)); + fn test_fpathconf_terminal_stdio_reports_terminal_and_namespace_values() { + use wasm_posix_shared::pathconf as pc; + + let proc = terminal_process(1); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::MAX_CANON), + Ok(None) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::VDISABLE), + Ok(Some(0)) + ); + assert_eq!( + sys_fpathconf(&proc, &mut host, 0, pc::PATH_MAX), + Ok(Some(NAMESPACE_PATH_MAX as i64)) + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_can_query_sched_euid_match() { - assert!(can_query_sched(7, 7, 7)); - assert!(can_query_sched(7, 7, 99)); - assert!(can_query_sched(7, 99, 7)); + fn test_pathconf_dev_stdin_uses_terminal_ofd() { + use wasm_posix_shared::pathconf as pc; + + let proc = terminal_process(1); + let mut host = MockHostIO::new(); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/dev/stdin", pc::VDISABLE), + Ok(Some(0)) + ); + assert!(host.pathconf_calls.is_empty()); } #[test] - fn test_can_query_sched_denies_when_only_ruid_matches() { - // seteuid-only drop: sender ruid still matches target but euid does - // not — POSIX sched_* still denies. - assert!(!can_query_sched(1000, 0, 0)); - assert!(!can_query_sched(99, 7, 7)); - } + fn test_pathconf_dev_tty_matches_controlling_stdio_kind() { + use wasm_posix_shared::pathconf as pc; - // ---- Phase 12: getrusage ---- + let captured = Process::new(1); + let mut captured_host = MockHostIO::new(); + assert_eq!( + sys_pathconf(&captured, &mut captured_host, b"/dev/tty", pc::PIPE_BUF), + Ok(None) + ); + assert_eq!( + sys_pathconf(&captured, &mut captured_host, b"/dev/tty", pc::VDISABLE), + Err(Errno::EINVAL) + ); - #[test] - fn test_getrusage_self() { - let mut proc = Process::new(1); - let mut buf = [0xFFu8; 144]; - let result = sys_getrusage(&mut proc, 0, &mut buf); - assert!(result.is_ok()); - // All fields should be zeroed - assert!(buf.iter().all(|&b| b == 0)); + let terminal = terminal_process(2); + let mut terminal_host = MockHostIO::new(); + assert_eq!( + sys_pathconf(&terminal, &mut terminal_host, b"/dev/tty", pc::VDISABLE), + Ok(Some(0)) + ); } #[test] - fn test_getrusage_children() { + fn test_fpathconf_socket_buffer_limit_is_indeterminate() { + use wasm_posix_shared::pathconf as pc; + use wasm_posix_shared::socket::{AF_UNIX, SOCK_STREAM}; + let mut proc = Process::new(1); - let mut buf = [0xFFu8; 144]; - let result = sys_getrusage(&mut proc, -1, &mut buf); - assert!(result.is_ok()); - assert!(buf.iter().all(|&b| b == 0)); + let mut host = MockHostIO::new(); + let (fd, _) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + + assert_eq!( + sys_fpathconf(&proc, &mut host, fd, pc::SOCK_MAXBUF), + Ok(None) + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_getrusage_invalid_who() { + fn test_synthetic_pathconf_and_fpathconf_use_root_backend() { + use wasm_posix_shared::pathconf as pc; + let mut proc = Process::new(1); - let mut buf = [0u8; 144]; - let result = sys_getrusage(&mut proc, 5, &mut buf); - assert_eq!(result, Err(Errno::EINVAL)); + let mut host = MockHostIO::new(); + host.pathconf_result = Ok(Some(777)); + + assert_eq!( + sys_pathconf(&proc, &mut host, b"/etc/mtab", pc::PATH_MAX), + Ok(Some(777)) + ); + let fd = sys_open(&mut proc, &mut host, b"/etc/mtab", O_RDONLY, 0).unwrap(); + assert_eq!( + sys_fpathconf(&proc, &mut host, fd, pc::PATH_MAX), + Ok(Some(777)) + ); + assert_eq!( + host.pathconf_calls, + vec![(b"/".to_vec(), pc::PATH_MAX), (b"/".to_vec(), pc::PATH_MAX)] + ); + assert!(host.fpathconf_calls.is_empty()); } #[test] - fn test_getrusage_buffer_too_small() { - let mut proc = Process::new(1); - let mut buf = [0u8; 10]; - let result = sys_getrusage(&mut proc, 0, &mut buf); - assert_eq!(result, Err(Errno::EINVAL)); + fn test_pathconf_name_table_is_complete_and_unique() { + let mut values = std::collections::HashSet::new(); + for &(_, value) in wasm_posix_shared::pathconf::ABI_NAMES { + assert!(validate_pathconf_name(value).is_ok()); + assert!(values.insert(value), "duplicate pathconf value {value}"); + } + assert_eq!(values.len(), 24); } - // ---- realpath ---- + // ---- getsockname/getpeername tests ---- #[test] - fn test_realpath_absolute() { + fn test_getsockname_socket() { + use wasm_posix_shared::socket::*; let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - let len = sys_realpath(&mut proc, &mut host, b"/tmp/test", &mut buf).unwrap(); - assert_eq!(&buf[..len], b"/tmp/test"); + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let mut buf = [0u8; 16]; + let n = sys_getsockname(&proc, fd0, &mut buf).unwrap(); + assert_eq!(n, 2); + assert_eq!(buf[0], 1); // AF_UNIX + assert_eq!(buf[1], 0); } #[test] - fn test_realpath_relative() { - let mut proc = Process::new(1); - proc.cwd = b"/home/user".to_vec(); - let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - let len = sys_realpath(&mut proc, &mut host, b"file.txt", &mut buf).unwrap(); - assert_eq!(&buf[..len], b"/home/user/file.txt"); + fn test_getsockname_non_socket() { + let proc = Process::new(1); + let mut buf = [0u8; 16]; + // fd 0 is stdin, not a socket + assert_eq!(sys_getsockname(&proc, 0, &mut buf), Err(Errno::ENOTSOCK)); } #[test] - fn test_realpath_dotdot() { + fn test_getpeername_connected() { + use wasm_posix_shared::socket::*; let mut proc = Process::new(1); - proc.cwd = b"/home/user".to_vec(); let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - let len = sys_realpath(&mut proc, &mut host, b"../file.txt", &mut buf).unwrap(); - assert_eq!(&buf[..len], b"/home/file.txt"); + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let mut buf = [0u8; 16]; + let n = sys_getpeername(&proc, fd0, &mut buf).unwrap(); + assert_eq!(n, 2); + assert_eq!(buf[0], 1); // AF_UNIX } + // ---- ftruncate tests ---- + #[test] - fn test_realpath_dot() { + fn test_ftruncate_regular_file() { let mut proc = Process::new(1); - proc.cwd = b"/home/user".to_vec(); let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - let len = sys_realpath(&mut proc, &mut host, b"./file.txt", &mut buf).unwrap(); - assert_eq!(&buf[..len], b"/home/user/file.txt"); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let result = sys_ftruncate(&mut proc, &mut host, fd, 100); + assert!(result.is_ok()); } #[test] - fn test_realpath_empty_path() { + fn test_ftruncate_negative_length() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mut buf = [0u8; 256]; - let result = sys_realpath(&mut proc, &mut host, b"", &mut buf); - assert_eq!(result, Err(Errno::ENOENT)); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let result = sys_ftruncate(&mut proc, &mut host, fd, -1); + assert_eq!(result, Err(Errno::EINVAL)); } #[test] - fn test_realpath_buffer_too_small() { + fn test_ftruncate_bad_fd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mut buf = [0u8; 3]; // Too small for "/tmp/test" - let result = sys_realpath(&mut proc, &mut host, b"/tmp/test", &mut buf); - assert_eq!(result, Err(Errno::ERANGE)); + let result = sys_ftruncate(&mut proc, &mut host, 99, 0); + assert_eq!(result, Err(Errno::EBADF)); } #[test] - fn test_fork_returns_enosys_with_mock() { + fn test_ftruncate_pipe_einval() { let mut proc = Process::new(1); - let host = MockHostIO::new(); - let result = sys_fork(&mut proc, &host); - assert_eq!(result, Err(Errno::ENOSYS)); + let mut host = MockHostIO::new(); + let (r, _w) = sys_pipe(&mut proc).unwrap(); + let result = sys_ftruncate(&mut proc, &mut host, r, 0); + assert_eq!(result, Err(Errno::EINVAL)); } #[test] - fn test_fork_child_fields_default_to_false() { - let proc = Process::new(1); - assert!(!proc.fork_child); - assert!(proc.fork_exec_path.is_none()); - assert!(proc.fork_exec_argv.is_none()); - assert!(proc.fork_fd_actions.is_empty()); + fn test_ftruncate_rdonly_einval() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/tmp/test", O_RDONLY, 0o644).unwrap(); + let result = sys_ftruncate(&mut proc, &mut host, fd, 0); + assert_eq!(result, Err(Errno::EINVAL)); } + // ---- fsync tests ---- + #[test] - fn test_execve_empty_path_returns_enoent() { + fn test_fsync_regular_file() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_execve(&mut proc, &mut host, b""); - assert_eq!(result, Err(Errno::ENOENT)); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let result = sys_fsync(&mut proc, &mut host, fd); + assert!(result.is_ok()); } #[test] - fn test_execve_delegates_to_host() { + fn test_fsync_bad_fd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_execve(&mut proc, &mut host, b"/bin/ls"); - assert!(result.is_ok()); + let result = sys_fsync(&mut proc, &mut host, 99); + assert_eq!(result, Err(Errno::EBADF)); } #[test] - fn test_alarm_sets_deadline_and_returns_zero_initially() { + fn test_fsync_pipe_einval() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let remaining = sys_alarm(&mut proc, &mut host, 5).unwrap(); - assert_eq!(remaining, 0); - assert!(proc.alarm_deadline_ns > 0); + let (r, _w) = sys_pipe(&mut proc).unwrap(); + let result = sys_fsync(&mut proc, &mut host, r); + assert_eq!(result, Err(Errno::EINVAL)); } #[test] - fn test_alarm_returns_previous_remaining() { + fn test_dup3_with_cloexec() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - sys_alarm(&mut proc, &mut host, 10).unwrap(); - let remaining = sys_alarm(&mut proc, &mut host, 5).unwrap(); - assert!(remaining > 0 && remaining <= 10); + // dup3 fd 0 to fd 5 with O_CLOEXEC + let result = sys_dup3(&mut proc, &mut host, 0, 5, O_CLOEXEC); + assert_eq!(result, Ok(5)); + let entry = proc.fd_table.get(5).unwrap(); + assert_eq!(entry.fd_flags, FD_CLOEXEC); } #[test] - fn test_alarm_zero_cancels() { + fn test_dup3_without_cloexec() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - sys_alarm(&mut proc, &mut host, 5).unwrap(); - let remaining = sys_alarm(&mut proc, &mut host, 0).unwrap(); - assert!(remaining > 0); - assert_eq!(proc.alarm_deadline_ns, 0); + let result = sys_dup3(&mut proc, &mut host, 0, 5, 0); + assert_eq!(result, Ok(5)); + let entry = proc.fd_table.get(5).unwrap(); + assert_eq!(entry.fd_flags, 0); } #[test] - fn test_sigsuspend_returns_eintr_when_signal_already_pending() { + fn test_dup3_same_fd_einval() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.signals.raise(2); // SIGINT - let result = sys_sigsuspend(&mut proc, &mut host, 0); - assert_eq!(result, Err(Errno::EINTR)); + let result = sys_dup3(&mut proc, &mut host, 0, 0, 0); + assert_eq!(result, Err(Errno::EINVAL)); } #[test] - fn test_sigsuspend_saves_old_mask_until_signal_dequeue() { + fn test_pipe2_cloexec() { let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - proc.signals.blocked = 0xFF; - proc.signals.raise(2); // SIGINT pending - let result = sys_sigsuspend(&mut proc, &mut host, 0); - let tid = crate::process_table::current_tid(); - assert_eq!(result, Err(Errno::EINTR)); - assert_eq!(proc.signals.blocked, 0); - assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); + let (r, w) = sys_pipe2(&mut proc, O_CLOEXEC).unwrap(); + let r_entry = proc.fd_table.get(r).unwrap(); + let w_entry = proc.fd_table.get(w).unwrap(); + assert_eq!(r_entry.fd_flags, FD_CLOEXEC); + assert_eq!(w_entry.fd_flags, FD_CLOEXEC); } #[test] - fn test_sigsuspend_returns_eagain_for_host_retry() { + fn test_pipe2_nonblock() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_sigsuspend(&mut proc, &mut host, 0); + let (r, _w) = sys_pipe2(&mut proc, O_NONBLOCK).unwrap(); + // Verify O_NONBLOCK is set — read empty pipe should return EAGAIN + let mut buf = [0u8; 16]; + let result = sys_read(&mut proc, &mut host, r, &mut buf); assert_eq!(result, Err(Errno::EAGAIN)); } #[test] - fn test_sigsuspend_cannot_block_sigkill() { + fn test_pipe2_both_flags() { + let mut proc = Process::new(1); + let (r, w) = sys_pipe2(&mut proc, O_CLOEXEC | O_NONBLOCK).unwrap(); + let r_entry = proc.fd_table.get(r).unwrap(); + let w_entry = proc.fd_table.get(w).unwrap(); + assert_eq!(r_entry.fd_flags, FD_CLOEXEC); + assert_eq!(w_entry.fd_flags, FD_CLOEXEC); + // Verify O_NONBLOCK is set on the OFDs + let r_ofd = proc.ofd_table.get(r_entry.ofd_ref.0).unwrap(); + let w_ofd = proc.ofd_table.get(w_entry.ofd_ref.0).unwrap(); + assert_ne!(r_ofd.status_flags & O_NONBLOCK, 0); + assert_ne!(w_ofd.status_flags & O_NONBLOCK, 0); + } + + #[test] + fn test_writev_multiple_buffers() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mask = u64::MAX; // try to block everything - let result = sys_sigsuspend(&mut proc, &mut host, mask); - assert_eq!(result, Err(Errno::EAGAIN)); - assert_eq!(proc.signals.blocked & crate::signal::sig_bit(SIGKILL), 0); - assert_eq!(proc.signals.blocked & crate::signal::sig_bit(SIGSTOP), 0); + let (r, w) = sys_pipe(&mut proc).unwrap(); + let bufs: &[&[u8]] = &[b"hello", b" ", b"world"]; + let n = sys_writev(&mut proc, &mut host, w, bufs).unwrap(); + assert_eq!(n, 11); + // Read back + let mut rbuf = [0u8; 32]; + let n2 = sys_read(&mut proc, &mut host, r, &mut rbuf).unwrap(); + assert_eq!(n2, 11); + assert_eq!(&rbuf[..11], b"hello world"); } #[test] - fn test_sigsuspend_saves_mask_for_host_retry() { + fn test_readv_multiple_buffers() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.signals.blocked = 0xFF; - let result = sys_sigsuspend(&mut proc, &mut host, 0); - let tid = crate::process_table::current_tid(); - assert_eq!(result, Err(Errno::EAGAIN)); - assert_eq!(proc.signals.blocked, 0); - assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); + let (r, w) = sys_pipe(&mut proc).unwrap(); + // Write data to pipe + sys_write(&mut proc, &mut host, w, b"helloworld").unwrap(); + // Read into multiple buffers + let mut buf1 = [0u8; 5]; + let mut buf2 = [0u8; 5]; + let mut buffers: [&mut [u8]; 2] = [&mut buf1, &mut buf2]; + let n = sys_readv(&mut proc, &mut host, r, &mut buffers).unwrap(); + assert_eq!(n, 10); + assert_eq!(&buf1, b"hello"); + assert_eq!(&buf2, b"world"); } - // ---- *at() syscalls with real dirfd ---- + #[test] + fn test_writev_empty_buffer() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (_r, w) = sys_pipe(&mut proc).unwrap(); + let bufs: &[&[u8]] = &[b"", b"data", b""]; + let n = sys_writev(&mut proc, &mut host, w, bufs).unwrap(); + assert_eq!(n, 4); + } - /// A mock HostIO that records the resolved paths passed to host calls. - #[derive(Debug, Clone, PartialEq, Eq)] - struct BindFbCall { - pid: i32, - addr: usize, - len: usize, - w: u32, - h: u32, - stride: u32, - fmt: u32, + #[test] + fn test_writev_bad_fd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let bufs: &[&[u8]] = &[b"hello"]; + let result = sys_writev(&mut proc, &mut host, 99, bufs); + assert_eq!(result, Err(Errno::EBADF)); } - #[derive(Debug, Clone, PartialEq, Eq)] - struct FbWriteCall { - pid: i32, - offset: usize, - len: usize, + #[test] + fn test_readv_bad_fd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf1 = [0u8; 5]; + let mut buffers: [&mut [u8]; 1] = [&mut buf1]; + let result = sys_readv(&mut proc, &mut host, 99, &mut buffers); + assert_eq!(result, Err(Errno::EBADF)); } - struct TrackingHostIO { - next_handle: i64, - last_open_path: Vec, - last_stat_path: Vec, - last_lstat_path: Vec, - last_unlink_path: Vec, - last_rmdir_path: Vec, - last_mkdir_path: Vec, - last_rename_old: Vec, - last_rename_new: Vec, - last_chmod_path: Vec, - last_chown_path: Vec, - last_access_path: Vec, - last_link_old: Vec, - last_link_new: Vec, - last_symlink_target: Vec, - last_symlink_linkpath: Vec, - last_readlink_path: Vec, - bind_framebuffer_calls: Vec, - unbind_framebuffer_calls: Vec, - fb_write_calls: Vec, + #[test] + fn test_readv_empty_buffers() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let (r, w) = sys_pipe(&mut proc).unwrap(); + sys_write(&mut proc, &mut host, w, b"data").unwrap(); + let mut buf1 = [0u8; 0]; + let mut buf2 = [0u8; 4]; + let mut buffers: [&mut [u8]; 2] = [&mut buf1, &mut buf2]; + let n = sys_readv(&mut proc, &mut host, r, &mut buffers).unwrap(); + assert_eq!(n, 4); + assert_eq!(&buf2, b"data"); } - impl TrackingHostIO { - fn new() -> Self { - TrackingHostIO { - next_handle: 100, - last_open_path: Vec::new(), - last_stat_path: Vec::new(), - last_lstat_path: Vec::new(), - last_unlink_path: Vec::new(), - last_rmdir_path: Vec::new(), - last_mkdir_path: Vec::new(), - last_rename_old: Vec::new(), - last_rename_new: Vec::new(), - last_chmod_path: Vec::new(), - last_chown_path: Vec::new(), - last_access_path: Vec::new(), - last_link_old: Vec::new(), - last_link_new: Vec::new(), - last_symlink_target: Vec::new(), - last_symlink_linkpath: Vec::new(), - last_readlink_path: Vec::new(), - bind_framebuffer_calls: Vec::new(), - unbind_framebuffer_calls: Vec::new(), - fb_write_calls: Vec::new(), - } - } + #[test] + fn test_getrlimit_nofile_default() { + let proc = Process::new(1); + let (soft, hard) = sys_getrlimit(&proc, 7).unwrap(); // RLIMIT_NOFILE + assert_eq!(soft, 1024); + assert_eq!(hard, 4096); } - impl HostIO for TrackingHostIO { - fn host_open(&mut self, path: &[u8], _flags: u32, _mode: u32) -> Result { - self.last_open_path = path.to_vec(); - let h = self.next_handle; - self.next_handle += 1; - Ok(h) - } - fn host_close(&mut self, _handle: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_read(&mut self, _handle: i64, buf: &mut [u8]) -> Result { - let n = buf.len().min(5); - buf[..n].copy_from_slice(&b"hello"[..n]); - Ok(n) - } - fn host_write(&mut self, _handle: i64, buf: &[u8]) -> Result { - Ok(buf.len()) - } - fn host_seek(&mut self, _handle: i64, _offset: i64, _whence: u32) -> Result { - Ok(0) - } - fn host_fstat(&mut self, _handle: i64) -> Result { - Ok(WasmStat { - st_dev: 0, - st_ino: 0, - st_mode: S_IFREG | 0o644, - st_nlink: 1, - st_uid: 0, - st_gid: 0, - st_size: 1024, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }) - } - fn host_stat(&mut self, path: &[u8]) -> Result { - self.last_stat_path = path.to_vec(); - let mode = test_default_mode(path); - Ok(WasmStat { - st_dev: 0, - st_ino: 1, - st_mode: mode, - st_nlink: 1, - st_uid: 0, - st_gid: 0, - st_size: 1024, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }) - } - fn host_lstat(&mut self, path: &[u8]) -> Result { - self.last_lstat_path = path.to_vec(); - // Return regular file/dir by default (not symlink) so realpath works - let mode = test_default_mode(path); - Ok(WasmStat { - st_dev: 0, - st_ino: 2, - st_mode: mode, - st_nlink: 1, - st_uid: 0, - st_gid: 0, - st_size: 1024, - st_atime_sec: 0, - st_atime_nsec: 0, - st_mtime_sec: 0, - st_mtime_nsec: 0, - st_ctime_sec: 0, - st_ctime_nsec: 0, - _pad: 0, - }) - } - fn host_mkdir(&mut self, path: &[u8], _mode: u32) -> Result<(), Errno> { - self.last_mkdir_path = path.to_vec(); - Ok(()) - } - fn host_rmdir(&mut self, path: &[u8]) -> Result<(), Errno> { - self.last_rmdir_path = path.to_vec(); - Ok(()) - } - fn host_unlink(&mut self, path: &[u8]) -> Result<(), Errno> { - self.last_unlink_path = path.to_vec(); - Ok(()) - } - fn host_rename(&mut self, oldpath: &[u8], newpath: &[u8]) -> Result<(), Errno> { - self.last_rename_old = oldpath.to_vec(); - self.last_rename_new = newpath.to_vec(); - Ok(()) - } - fn host_link(&mut self, oldpath: &[u8], newpath: &[u8]) -> Result<(), Errno> { - self.last_link_old = oldpath.to_vec(); - self.last_link_new = newpath.to_vec(); - Ok(()) - } - fn host_symlink(&mut self, target: &[u8], linkpath: &[u8]) -> Result<(), Errno> { - self.last_symlink_target = target.to_vec(); - self.last_symlink_linkpath = linkpath.to_vec(); - Ok(()) - } - fn host_readlink(&mut self, path: &[u8], buf: &mut [u8]) -> Result { - self.last_readlink_path = path.to_vec(); - let target = b"/target"; - let n = buf.len().min(target.len()); - buf[..n].copy_from_slice(&target[..n]); - Ok(n) - } - fn host_chmod(&mut self, path: &[u8], _mode: u32) -> Result<(), Errno> { - self.last_chmod_path = path.to_vec(); - Ok(()) - } - fn host_chown(&mut self, path: &[u8], _uid: u32, _gid: u32) -> Result<(), Errno> { - self.last_chown_path = path.to_vec(); - Ok(()) - } - fn host_access(&mut self, path: &[u8], _amode: u32) -> Result<(), Errno> { - self.last_access_path = path.to_vec(); - Ok(()) - } - fn host_opendir(&mut self, _path: &[u8]) -> Result { - Ok(200) - } - fn host_readdir( - &mut self, - _handle: i64, - _name_buf: &mut [u8], - ) -> Result, Errno> { - Ok(None) - } - fn host_closedir(&mut self, _handle: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_clock_gettime(&mut self, _clock_id: u32) -> Result<(i64, i64), Errno> { - Ok((0, 0)) - } - fn host_nanosleep(&mut self, _seconds: i64, _nanoseconds: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_ftruncate(&mut self, _handle: i64, _length: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_fsync(&mut self, _handle: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_fchmod(&mut self, _handle: i64, _mode: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_fchown(&mut self, _handle: i64, _uid: u32, _gid: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_kill(&mut self, _pid: i32, _sig: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_exec(&mut self, _path: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_set_alarm(&mut self, _seconds: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_set_posix_timer( - &mut self, - _timer_id: i32, - _signo: i32, - _value_ms: i64, - _interval_ms: i64, - ) -> Result<(), Errno> { - Ok(()) - } - fn host_sigsuspend_wait(&mut self) -> Result { - Err(Errno::EINTR) - } - fn host_call_signal_handler( - &mut self, - _handler_index: u32, - _signum: u32, - _sa_flags: u32, - ) -> Result<(), Errno> { - Ok(()) - } - fn host_getrandom(&mut self, buf: &mut [u8]) -> Result { - for (i, b) in buf.iter_mut().enumerate() { - *b = (i & 0xFF) as u8; - } - Ok(buf.len()) - } - fn host_utimensat( - &mut self, - _path: &[u8], - _atime_sec: i64, - _atime_nsec: i64, - _mtime_sec: i64, - _mtime_nsec: i64, - ) -> Result<(), Errno> { - Ok(()) - } - fn host_waitpid(&mut self, _pid: i32, _options: u32) -> Result<(i32, i32), Errno> { - Err(Errno::ECHILD) - } - fn host_net_connect( - &mut self, - _handle: i32, - _addr: &[u8], - _port: u16, - ) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) - } - fn host_net_connect_status(&mut self, _handle: i32) -> Result<(), Errno> { - Err(Errno::ECONNREFUSED) - } - fn host_net_send( - &mut self, - _handle: i32, - _data: &[u8], - _flags: u32, - ) -> Result { - Err(Errno::ENOTCONN) - } - fn host_net_recv( - &mut self, - _handle: i32, - _len: u32, - _flags: u32, - _buf: &mut [u8], - ) -> Result { - Err(Errno::ENOTCONN) - } - fn host_net_close(&mut self, _handle: i32) -> Result<(), Errno> { - Ok(()) - } - fn host_net_listen(&mut self, _fd: i32, _port: u16, _addr: &[u8; 4]) -> Result<(), Errno> { - Ok(()) - } - fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { - Err(Errno::ENOENT) - } - fn host_fcntl_lock( - &mut self, - _path: &[u8], - _pid: u32, - _cmd: u32, - _lock_type: u32, - _start: i64, - _len: i64, - _result_buf: &mut [u8], - ) -> Result<(), Errno> { - Ok(()) - } - fn host_fork(&self) -> i32 { - -(Errno::ENOSYS as i32) - } - fn host_futex_wait( - &mut self, - _addr: usize, - _expected: u32, - _timeout_ns: i64, - ) -> Result { - Err(Errno::EAGAIN) - } - fn host_futex_wake(&mut self, _addr: usize, _count: u32) -> Result { - Ok(0) - } - fn host_clone( - &mut self, - _fn_ptr: usize, - _arg: usize, - _stack_ptr: usize, - _tls_ptr: usize, - _ctid_ptr: usize, - ) -> Result { - Err(Errno::ENOSYS) - } - fn bind_framebuffer( - &mut self, - pid: i32, - addr: usize, - len: usize, - w: u32, - h: u32, - stride: u32, - fmt: u32, - ) { - self.bind_framebuffer_calls.push(BindFbCall { - pid, - addr, - len, - w, - h, - stride, - fmt, - }); - } - fn unbind_framebuffer(&mut self, pid: i32) { - self.unbind_framebuffer_calls.push(pid); - } - fn fb_write(&mut self, pid: i32, offset: usize, bytes: &[u8]) { - self.fb_write_calls.push(FbWriteCall { - pid, - offset, - len: bytes.len(), - }); - } - } - - /// Helper: open a directory and return its fd. - fn open_dir_fd(proc: &mut Process, host: &mut dyn HostIO, dir_path: &[u8]) -> i32 { - sys_open(proc, host, dir_path, O_RDONLY | O_DIRECTORY, 0).expect("open dir should succeed") + #[test] + fn test_getrlimit_stack_default() { + let proc = Process::new(1); + let (soft, hard) = sys_getrlimit(&proc, 3).unwrap(); // RLIMIT_STACK + assert_eq!(soft, 8 * 1024 * 1024); + assert_eq!(hard, u64::MAX); } #[test] - fn test_openat_with_real_dirfd() { + fn test_setrlimit_and_getrlimit() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/home/user/dir"); - // open a file relative to /home/user/dir - let fd = sys_openat(&mut proc, &mut host, dirfd, b"file.txt", O_RDONLY, 0); - assert!(fd.is_ok()); - assert_eq!(host.last_open_path, b"/home/user/dir/file.txt"); + sys_setrlimit(&mut proc, 7, 512, 2048).unwrap(); // RLIMIT_NOFILE + let (soft, hard) = sys_getrlimit(&proc, 7).unwrap(); + assert_eq!(soft, 512); + assert_eq!(hard, 2048); } #[test] - fn test_fstatat_with_real_dirfd() { + fn test_setrlimit_soft_exceeds_hard() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); - let result = sys_fstatat(&mut proc, &mut host, dirfd, b"subdir", 0); - assert!(result.is_ok()); - assert_eq!(host.last_stat_path, b"/var/dir/subdir"); + let result = sys_setrlimit(&mut proc, 7, 5000, 1000); + assert_eq!(result, Err(Errno::EINVAL)); } #[test] - fn test_fstatat_with_real_dirfd_nofollow() { + fn test_setrlimit_nofile_enforced() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); - let result = sys_fstatat(&mut proc, &mut host, dirfd, b"link", AT_SYMLINK_NOFOLLOW); - assert!(result.is_ok()); - assert_eq!(host.last_lstat_path, b"/var/dir/link"); - } + let mut host = MockHostIO::new(); - #[test] - fn test_unlinkat_with_real_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/tmp/dir"); - let result = sys_unlinkat(&mut proc, &mut host, dirfd, b"file", 0); - assert!(result.is_ok()); - assert_eq!(host.last_unlink_path, b"/tmp/dir/file"); - } + // Lower RLIMIT_NOFILE soft limit to 5 + sys_setrlimit(&mut proc, 7, 5, 4096).unwrap(); - #[test] - fn test_unlinkat_removedir_with_real_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/tmp/dir"); - let result = sys_unlinkat(&mut proc, &mut host, dirfd, b"subdir", AT_REMOVEDIR); - assert!(result.is_ok()); - assert_eq!(host.last_rmdir_path, b"/tmp/dir/subdir"); - } + // fds 0,1,2 are pre-opened (stdio). We can open 2 more (fd 3, 4). + let fd3 = sys_open(&mut proc, &mut host, b"/tmp/a", O_RDWR | O_CREAT, 0o644).unwrap(); + assert_eq!(fd3, 3); + let fd4 = sys_open(&mut proc, &mut host, b"/tmp/b", O_RDWR | O_CREAT, 0o644).unwrap(); + assert_eq!(fd4, 4); - #[test] - fn test_mkdirat_with_real_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/home/dir"); - let result = sys_mkdirat(&mut proc, &mut host, dirfd, b"newdir", 0o755); - assert!(result.is_ok()); - assert_eq!(host.last_mkdir_path, b"/home/dir/newdir"); + // fd 5 should fail with EMFILE + let result = sys_open(&mut proc, &mut host, b"/tmp/c", O_RDWR | O_CREAT, 0o644); + assert_eq!(result, Err(Errno::EMFILE)); } - #[test] - fn test_renameat_with_real_dirfds() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd1 = open_dir_fd(&mut proc, &mut host, b"/src/dir"); - let dirfd2 = open_dir_fd(&mut proc, &mut host, b"/dst/dir"); - let result = sys_renameat(&mut proc, &mut host, dirfd1, b"old", dirfd2, b"new"); - assert!(result.is_ok()); - assert_eq!(host.last_rename_old, b"/src/dir/old"); - assert_eq!(host.last_rename_new, b"/dst/dir/new"); + fn fsize_signal_pending(proc: &Process) -> bool { + proc.signal_pending_for(proc.pid, SIGXFSZ) } - #[test] - fn test_linkat_with_real_dirfds() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/data/dir"); - let result = sys_linkat(&mut proc, &mut host, dirfd, b"existing", dirfd, b"link", 0); - assert!(result.is_ok()); - assert_eq!(host.last_link_old, b"/data/dir/existing"); - assert_eq!(host.last_link_new, b"/data/dir/link"); + fn clear_fsize_signal(proc: &mut Process) { + proc.main_thread_signals.clear_pending(SIGXFSZ); + proc.signals.clear(SIGXFSZ); } #[test] - fn test_symlinkat_with_real_dirfd() { + fn test_rlimit_fsize_write_short_zero_then_efbig() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/opt/dir"); - let result = sys_symlinkat(&mut proc, &mut host, b"../target", dirfd, b"link"); - assert!(result.is_ok()); - // target is stored as-is, only linkpath is resolved - assert_eq!(host.last_symlink_target, b"../target"); - assert_eq!(host.last_symlink_linkpath, b"/opt/dir/link"); - } + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize_test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); - #[test] - fn test_readlinkat_with_real_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/etc/dir"); - let mut buf = [0u8; 256]; - let result = sys_readlinkat(&mut proc, &mut host, dirfd, b"link", &mut buf); - assert!(result.is_ok()); - assert_eq!(host.last_readlink_path, b"/etc/dir/link"); - } + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); - #[test] - fn test_fchmodat_with_real_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); - let result = sys_fchmodat(&mut proc, &mut host, dirfd, b"file", 0o644, 0); - assert!(result.is_ok()); - assert_eq!(host.last_chmod_path, b"/var/dir/file"); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcde"), Ok(5)); + assert_eq!(sys_write(&mut proc, &mut host, fd, &[0u8; 10]), Ok(5)); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_write(&mut proc, &mut host, fd, b""), Ok(0)); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EFBIG)); + assert!(fsize_signal_pending(&proc)); } #[test] - fn test_fchownat_with_real_dirfd() { + fn test_rlimit_fsize_pwrite_short_zero_then_efbig() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); - let result = sys_fchownat(&mut proc, &mut host, dirfd, b"file", 1000, 1000, 0); - assert!(result.is_ok()); - assert_eq!(host.last_chown_path, b"/var/dir/file"); - } + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize_pwrite_test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); - #[test] - fn test_faccessat_with_real_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); - let result = sys_faccessat(&mut proc, &mut host, dirfd, b"file", 0, 0); - assert!(result.is_ok()); - assert_eq!(host.last_stat_path, b"/var/dir/file"); - } + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); - #[test] - fn test_at_absolute_path_ignores_dirfd() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/wrong/dir"); - // Absolute path should ignore dirfd - let result = sys_fstatat(&mut proc, &mut host, dirfd, b"/absolute/path", 0); - assert!(result.is_ok()); - assert_eq!(host.last_stat_path, b"/absolute/path"); - } + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"abcdefghij", 5), + Ok(5) + ); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_pwrite(&mut proc, &mut host, fd, b"", 10), Ok(0)); + assert!(!fsize_signal_pending(&proc)); - #[test] - fn test_at_enotdir_when_dirfd_is_regular_file() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - // Open a regular file (not a directory) - let fd = sys_open(&mut proc, &mut host, b"/some/file.txt", O_RDONLY, 0).unwrap(); - // Try to use it as a dirfd - should get ENOTDIR - let result = sys_fstatat(&mut proc, &mut host, fd, b"relative", 0); - assert!(matches!(result, Err(Errno::ENOTDIR))); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"x", 10), + Err(Errno::EFBIG) + ); + assert!(fsize_signal_pending(&proc)); } #[test] - fn test_openat_stores_resolved_path_in_ofd() { + fn test_rlimit_fsize_budget_above_wasm32_range_does_not_wrap() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let dirfd = open_dir_fd(&mut proc, &mut host, b"/base/dir"); - let fd = sys_openat( + let mut host = MockHostIO::new(); + let fd = sys_open( &mut proc, &mut host, - dirfd, - b"child/dir", - O_RDONLY | O_DIRECTORY, - 0, + b"/tmp/fsize-large-limit", + O_WRONLY | O_CREAT, + 0o644, ) .unwrap(); - // The new OFD should have the resolved path stored - let entry = proc.fd_table.get(fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - assert_eq!(ofd.path, b"/base/dir/child/dir"); + let limit = (1u64 << 32) + 5; + sys_setrlimit(&mut proc, RLIMIT_FSIZE, limit, limit).unwrap(); + + assert_eq!( + write_operation_budget(&mut proc, &mut host, fd, Some(0), 10), + Ok(10) + ); + assert_eq!( + sys_pwrite(&mut proc, &mut host, fd, b"0123456789", 1i64 << 32), + Ok(5) + ); + assert!(!fsize_signal_pending(&proc)); } #[test] - fn test_inet_socket_creation() { - let mut proc = Process::new(1); + fn test_rlimit_fsize_does_not_limit_captured_pipe_or_terminal_stdout() { + let mut captured = Process::new(1); + let mut terminal = Process::new_with_stdio(2, crate::process::StdioConfig::terminal()); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - assert!(fd >= 0); + + sys_setrlimit(&mut captured, RLIMIT_FSIZE, 1, 1).unwrap(); + sys_setrlimit(&mut terminal, RLIMIT_FSIZE, 1, 1).unwrap(); + + assert_eq!(sys_write(&mut captured, &mut host, 1, b"abc"), Ok(3)); + assert_eq!(sys_write(&mut terminal, &mut host, 1, b"def"), Ok(3)); + assert!(!fsize_signal_pending(&captured)); + assert!(!fsize_signal_pending(&terminal)); } #[test] - fn test_inet_connect_returns_econnrefused_with_mock() { - // MockHostIO returns ECONNREFUSED for host_net_connect + fn test_rlimit_fsize_writev_and_pwritev_use_one_operation_budget() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - // sockaddr_in: AF_INET(2) + port(80) + IP(127.0.0.1) - let addr = [2, 0, 0, 80, 127, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]; - let err = sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(); - assert_eq!(err, Errno::ECONNREFUSED); + let write_fd = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize-writev", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let pwrite_fd = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize-pwritev", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 5, 5).unwrap(); + let iovecs: &[&[u8]] = &[b"ab", b"cde", b"f"]; + + assert_eq!(sys_writev(&mut proc, &mut host, write_fd, iovecs), Ok(5)); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_write(&mut proc, &mut host, write_fd, b"x"), Err(Errno::EFBIG)); + assert!(fsize_signal_pending(&proc)); + + clear_fsize_signal(&mut proc); + assert_eq!( + sys_pwritev(&mut proc, &mut host, pwrite_fd, iovecs, 0), + Ok(5) + ); + assert!(!fsize_signal_pending(&proc)); + assert_eq!( + sys_pwrite(&mut proc, &mut host, pwrite_fd, b"x", 5), + Err(Errno::EFBIG) + ); + assert!(fsize_signal_pending(&proc)); } #[test] - fn test_inet_send_on_unconnected_returns_enotconn() { + fn test_rlimit_fsize_append_uses_file_end() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let err = sys_send(&mut proc, &mut host, fd, b"hello", 0).unwrap_err(); - assert_eq!(err, Errno::ENOTCONN); + host.seek_end = 8; + host.stat_size = 8; + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize-append", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); + + assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcde"), Ok(2)); + let entry = proc.fd_table.get(fd).unwrap(); + assert_eq!(proc.ofd_table.get(entry.ofd_ref.0).unwrap().offset, 10); + assert_eq!(host.seek_calls.iter().filter(|call| call.2 == SEEK_END).count(), 1); + assert!(!fsize_signal_pending(&proc)); } #[test] - fn test_unix_socket_connect_still_returns_econnrefused() { - // AF_UNIX SOCK_STREAM connect to nonexistent path should fail with ECONNREFUSED + fn test_rlimit_fsize_memfd_write_pwrite_and_ftruncate() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; // AF_UNIX - let path = b"/tmp/noexist.sock"; - addr[2..2 + path.len()].copy_from_slice(path); - let addrlen = 2 + path.len() + 1; - let err = sys_connect(&mut proc, &mut host, fd, &addr[..addrlen]).unwrap_err(); - assert_eq!(err, Errno::ECONNREFUSED); + let fd = sys_memfd_create(&mut proc, b"fsize", 0).unwrap(); + + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 20), Ok(())); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 15), Ok(())); + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 15), Ok(())); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 0), Ok(())); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 5, 5).unwrap(); + + assert_eq!(sys_write(&mut proc, &mut host, fd, b"abcdefgh"), Ok(5)); + assert_eq!(sys_fstat(&mut proc, &mut host, fd).unwrap().st_size, 5); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_write(&mut proc, &mut host, fd, b"x"), Err(Errno::EFBIG)); + + clear_fsize_signal(&mut proc); + assert_eq!(sys_pwrite(&mut proc, &mut host, fd, b"xyz", 4), Ok(1)); + assert!(!fsize_signal_pending(&proc)); + assert_eq!(sys_pwrite(&mut proc, &mut host, fd, b"x", 5), Err(Errno::EFBIG)); + + clear_fsize_signal(&mut proc); + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 6), Err(Errno::EFBIG)); + assert!(fsize_signal_pending(&proc)); + assert_eq!(sys_fstat(&mut proc, &mut host, fd).unwrap().st_size, 5); } #[test] - fn test_unix_dgram_connect_succeeds_as_bit_bucket() { + fn test_rlimit_fsize_copy_operations_do_not_overadvance_source() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); - let addr = [1, 0]; // AF_UNIX family - sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); - // Write should succeed and discard data - let n = sys_write(&mut proc, &mut host, fd, b"hello syslog").unwrap(); - assert_eq!(n, 12); + let source = sys_memfd_create(&mut proc, b"source", 0).unwrap(); + let copy_dest = sys_memfd_create(&mut proc, b"copy-dest", 0).unwrap(); + let send_dest = sys_memfd_create(&mut proc, b"send-dest", 0).unwrap(); + sys_write(&mut proc, &mut host, source, b"0123456789").unwrap(); + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 5, 5).unwrap(); + + assert_eq!( + sys_copy_file_range(&mut proc, &mut host, source, None, copy_dest, None, 10), + Ok(5) + ); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(5)); + assert!(!fsize_signal_pending(&proc)); + + assert_eq!( + sys_copy_file_range(&mut proc, &mut host, source, None, copy_dest, None, 1), + Err(Errno::EFBIG) + ); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(5)); + + clear_fsize_signal(&mut proc); + sys_lseek(&mut proc, &mut host, source, 0, SEEK_SET).unwrap(); + assert_eq!( + sys_sendfile(&mut proc, &mut host, send_dest, source, -1, 10), + Ok(5) + ); + assert_eq!(sys_lseek(&mut proc, &mut host, source, 0, SEEK_CUR), Ok(5)); + assert!(!fsize_signal_pending(&proc)); } #[test] - fn test_unix_stream_connect_same_process() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); - let mut proc = Process::new(9001); + fn test_rlimit_fsize_transfer_preflight_is_side_effect_free() { + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let path = b"/tmp/connect_9001.sock"; - // Clean up any stale registration from a prior test run - let resolved = crate::path::resolve_path(path, &proc.cwd); - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + host.stat_size = 8; + host.seek_end = 8; + let output = sys_open( + &mut proc, + &mut host, + b"/tmp/fsize-transfer-append", + O_WRONLY | O_CREAT | O_APPEND, + 0o644, + ) + .unwrap(); - // Create and bind a listener - let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); // AF_UNIX, SOCK_STREAM - let mut addr = [0u8; 110]; - addr[0] = 1; // AF_UNIX - addr[2..2 + path.len()].copy_from_slice(path); - let addrlen = 2 + path.len() + 1; - sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); - sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); - - // Connect a client - let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); - - // Client should be connected - let entry = proc.fd_table.get(client_fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = proc.sockets.get(sock_idx).unwrap(); - assert_eq!(sock.state, crate::socket::SocketState::Connected); - - // Server should have a pending connection - let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); - assert!(accepted_fd >= 0); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 5, 10).unwrap(); + assert_eq!( + sys_sendfile(&mut proc, &mut host, output, 9999, -1, 1), + Err(Errno::EBADF) + ); + assert!(!fsize_signal_pending(&proc)); + assert!(host.seek_calls.is_empty()); - // Clean up - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + let input = sys_memfd_create(&mut proc, b"invalid-offset-input", 0).unwrap(); + assert_eq!( + sys_copy_file_range( + &mut proc, + &mut host, + input, + Some(-1), + output, + None, + 1, + ), + Err(Errno::EINVAL) + ); + assert_eq!(sys_lseek(&mut proc, &mut host, input, 0, SEEK_CUR), Ok(0)); + assert!(!fsize_signal_pending(&proc)); + assert!(host.seek_calls.is_empty()); + assert_eq!( + sys_splice( + &mut proc, + &mut host, + input, + Some(-1), + output, + None, + 1, + 0, + ), + Err(Errno::EINVAL) + ); + assert_eq!(sys_lseek(&mut proc, &mut host, input, 0, SEEK_CUR), Ok(0)); + assert!(!fsize_signal_pending(&proc)); + assert!(host.seek_calls.is_empty()); + + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 10, 10).unwrap(); + let empty_input = sys_memfd_create(&mut proc, b"empty-input", 0).unwrap(); + let output_entry = proc.fd_table.get(output).unwrap(); + let output_ofd = output_entry.ofd_ref.0; + let original_offset = proc.ofd_table.get(output_ofd).unwrap().offset; + assert_eq!( + sys_sendfile(&mut proc, &mut host, output, empty_input, -1, 4), + Ok(0) + ); + assert_eq!(proc.ofd_table.get(output_ofd).unwrap().offset, original_offset); + assert!(host.seek_calls.is_empty()); + assert!(!fsize_signal_pending(&proc)); } #[test] - fn test_unix_stream_connect_pushes_accept_wakeup() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); - let mut proc = Process::new(9012); + fn test_ftruncate_and_fallocate_rlimit_fsize() { + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let path = b"/tmp/wakeup_9012.sock"; - let resolved = crate::path::resolve_path(path, &proc.cwd); - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); - - let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; // AF_UNIX - addr[2..2 + path.len()].copy_from_slice(path); - let addrlen = 2 + path.len() + 1; - sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); - sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); - - let accept_idx = { - let entry = proc.fd_table.get(server_fd).unwrap(); - let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); - let sock_idx = (-(ofd.host_handle + 1)) as usize; - proc.sockets.get(sock_idx).unwrap().accept_wake_idx.unwrap() - }; + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/ftrunc_test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); - let mut wake_buf = [0u8; 16]; - crate::wakeup::drain(&mut wake_buf, 8); + sys_setrlimit(&mut proc, RLIMIT_FSIZE, 100, 100).unwrap(); - let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 50), Ok(())); + assert_eq!(sys_ftruncate(&mut proc, &mut host, fd, 200), Err(Errno::EFBIG)); + assert!(fsize_signal_pending(&proc)); - let count = crate::wakeup::drain(&mut wake_buf, 8); - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + clear_fsize_signal(&mut proc); + assert_eq!(sys_fallocate(&mut proc, &mut host, fd, 0, 2048), Err(Errno::EFBIG)); + assert!(fsize_signal_pending(&proc)); - assert_eq!(count, 1); + clear_fsize_signal(&mut proc); assert_eq!( - u32::from_le_bytes([wake_buf[0], wake_buf[1], wake_buf[2], wake_buf[3]]), - accept_idx + sys_fallocate(&mut proc, &mut host, fd, i64::MAX, 1), + Err(Errno::EFBIG) ); - assert_eq!(wake_buf[4], crate::wakeup::WAKE_ACCEPT); + assert!(!fsize_signal_pending(&proc)); } #[test] - fn test_unix_stream_connect_no_listener() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); - let mut proc = Process::new(9002); - let mut host = MockHostIO::new(); - let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; // AF_UNIX - let path = b"/tmp/noexist_9002.sock"; - addr[2..2 + path.len()].copy_from_slice(path); - let addrlen = 2 + path.len() + 1; - let err = sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap_err(); - assert_eq!(err, Errno::ECONNREFUSED); - } + fn test_poll_socket_pollerr() { + use wasm_posix_shared::poll::*; + use wasm_posix_shared::socket::*; - #[test] - fn test_unix_stream_bidirectional_data() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); - let mut proc = Process::new(9003); + let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let path = b"/tmp/bidir_9003.sock"; - // Clean up any stale registration from a prior test run - let resolved = crate::path::resolve_path(path, &proc.cwd); - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); - - // Set up listener - let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; - addr[2..2 + path.len()].copy_from_slice(path); - let addrlen = 2 + path.len() + 1; - sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); - sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); - - // Connect client - let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); - let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + let (fd0, _fd1) = sys_socketpair(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); - // Client sends, server receives - let msg = b"hello from client"; - let sent = sys_send(&mut proc, &mut host, client_fd, msg, 0).unwrap(); - assert_eq!(sent, msg.len()); - let mut buf = [0u8; 64]; - let recvd = sys_recv(&mut proc, &mut host, accepted_fd, &mut buf, 0).unwrap(); - assert_eq!(&buf[..recvd], msg); + // Shutdown both directions to trigger POLLERR + sys_shutdown(&mut proc, &mut host, fd0, SHUT_RDWR).unwrap(); - // Server sends, client receives - let reply = b"hello from server"; - let sent = sys_send(&mut proc, &mut host, accepted_fd, reply, 0).unwrap(); - assert_eq!(sent, reply.len()); - let recvd = sys_recv(&mut proc, &mut host, client_fd, &mut buf, 0).unwrap(); - assert_eq!(&buf[..recvd], reply); + let mut fds = [WasmPollFd { + fd: fd0, + events: POLLIN | POLLOUT, + revents: 0, + }]; + let result = sys_poll(&mut proc, &mut host, &mut fds, 0).unwrap(); + assert!(result > 0); + assert_ne!(fds[0].revents & POLLERR, 0); + } - // Clean up - unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + #[test] + fn test_getrlimit_invalid_resource() { + let proc = Process::new(1); + let result = sys_getrlimit(&proc, 99); + assert_eq!(result, Err(Errno::EINVAL)); } + // ---- truncate tests ---- + #[test] - fn test_stat_unix_socket_path() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + fn test_truncate_path() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9020; - let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; - let path = b"/tmp/stat.sock"; - addr[2..2 + path.len()].copy_from_slice(path); - sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - - let st = sys_stat(&mut proc, &mut host, b"/tmp/stat.sock").unwrap(); - assert_eq!( - st.st_mode & wasm_posix_shared::mode::S_IFMT, - wasm_posix_shared::mode::S_IFSOCK - ); - - // Cleanup - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - registry.cleanup_process(9020); + let result = sys_truncate(&mut proc, &mut host, b"/tmp/test", 100); + assert!(result.is_ok()); } + // ---- fdatasync tests ---- + #[test] - fn test_unlink_unix_socket_path() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + fn test_fdatasync() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9021; - let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; - let path = b"/tmp/unlink.sock"; - addr[2..2 + path.len()].copy_from_slice(path); - sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - - // Socket path should exist - assert!(sys_stat(&mut proc, &mut host, b"/tmp/unlink.sock").is_ok()); - - // Unlink removes the path from registry - sys_unlink(&mut proc, &mut host, b"/tmp/unlink.sock").unwrap(); - - // Another socket can now bind to the same path - let fd2 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - sys_bind(&mut proc, &mut host, fd2, &addr[..2 + path.len() + 1]).unwrap(); - - // Cleanup - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - registry.cleanup_process(9021); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let result = sys_fdatasync(&mut proc, &mut host, fd); + assert!(result.is_ok()); } + // ---- fchmod tests ---- + #[test] - fn test_fstat_socket_fd() { + fn test_fchmod() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let st = sys_fstat(&mut proc, &mut host, fd).unwrap(); - assert_eq!( - st.st_mode & wasm_posix_shared::mode::S_IFMT, - wasm_posix_shared::mode::S_IFSOCK - ); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let result = sys_fchmod(&mut proc, &mut host, fd, 0o755); + assert!(result.is_ok()); } #[test] - fn test_getsockname_unix_with_path() { - let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + fn test_fchmod_pipe_accepted() { + // Linux accepts fchmod on non-regular fds (pipes, sockets, devices) + // even though the mode change has no observable effect. dinit and + // other daemons rely on this — pre-relaxation EINVAL aborted them + // mid-startup. let mut proc = Process::new(1); let mut host = MockHostIO::new(); - proc.pid = 9022; - let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); - let mut addr = [0u8; 110]; - addr[0] = 1; - let path = b"/tmp/getsockname.sock"; - addr[2..2 + path.len()].copy_from_slice(path); - sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); - - let mut buf = [0u8; 128]; - let n = sys_getsockname(&proc, fd, &mut buf).unwrap(); - assert_eq!(buf[0], 1); // AF_UNIX - assert!(n >= 2 + path.len()); - assert_eq!(&buf[2..2 + path.len()], path); - - // Cleanup - let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; - registry.cleanup_process(9022); + let (r, _w) = sys_pipe(&mut proc).unwrap(); + let result = sys_fchmod(&mut proc, &mut host, r, 0o755); + assert_eq!(result, Ok(())); } - #[test] - fn test_clock_getres_per_process_cpu_clock() { + // ---- fchown tests ---- + + #[test] + fn test_fchown() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open( + &mut proc, + &mut host, + b"/tmp/test", + O_WRONLY | O_CREAT, + 0o644, + ) + .unwrap(); + let result = sys_fchown(&mut proc, &mut host, fd, 1000, 1000); + assert!(result.is_ok()); + } + + // ----------------------------------------------------------------------- + // Process group and session tests + // ----------------------------------------------------------------------- + + #[test] + fn test_getpgrp_default() { let proc = Process::new(1); - // clock_getcpuclockid(pid) encodes as (-pid-1)*8 + 2 - // For pid=1: (-1-1)*8 + 2 = -16 + 2 = -14, as u32 = 4294967282 - let clock_id = ((-1i32 - 1) * 8 + 2) as u32; - let res = sys_clock_getres(&proc, clock_id).unwrap(); - assert_eq!(res.tv_sec, 0); - assert_eq!(res.tv_nsec, 1_000_000); + assert_eq!(sys_getpgrp(&proc), 1); // pgid == pid } #[test] - fn test_getaddrinfo_returns_enoent_with_mock() { + fn test_getpgid_self() { + let proc = Process::new(1); + assert_eq!(sys_getpgid(&proc, 0), Ok(1)); // pid=0 means current process + assert_eq!(sys_getpgid(&proc, 1), Ok(1)); // pid=self works too + } + + #[test] + fn test_getpgid_other_esrch() { + let proc = Process::new(1); + assert_eq!(sys_getpgid(&proc, 999), Err(Errno::ESRCH)); + } + + #[test] + fn test_setpgid_self() { + let mut proc = Process::new(2); + proc.sid = 1; // Not a session leader (child of pid 1) + let result = sys_setpgid(&mut proc, 0, 42); // pid=0 means self + assert!(result.is_ok()); + assert_eq!(proc.pgid, 42); + } + + #[test] + fn test_setpgid_zero_pgid() { + let mut proc = Process::new(2); + proc.sid = 1; // Not a session leader + let result = sys_setpgid(&mut proc, 0, 0); // pgid=0 means use pid + assert!(result.is_ok()); + assert_eq!(proc.pgid, 2); // pgid set to pid + } + + #[test] + fn test_setpgid_session_leader_eperm() { + let mut proc = Process::new(1); + // Make it a session leader by calling setsid + let _ = sys_setsid(&mut proc); + assert_eq!(proc.sid, proc.pid); // now a session leader + let result = sys_setpgid(&mut proc, 0, 42); + assert_eq!(result, Err(Errno::EPERM)); + } + + #[test] + fn test_setpgid_other_process_esrch() { + let mut proc = Process::new(1); + let result = sys_setpgid(&mut proc, 999, 42); // pid != self + assert_eq!(result, Err(Errno::ESRCH)); + } + + #[test] + fn test_getsid_self() { + let proc = Process::new(1); + assert_eq!(sys_getsid(&proc, 0), Ok(0)); // initial process has sid=0 (not a session leader) + } + + #[test] + fn test_getsid_other_esrch() { + let proc = Process::new(1); + assert_eq!(sys_getsid(&proc, 999), Err(Errno::ESRCH)); + } + + #[test] + fn test_setsid() { + // Simulate a child process (pid=2) that inherited parent's session (sid=1) + let mut proc = Process::new(2); + proc.sid = 1; + proc.pgid = 1; + let result = sys_setsid(&mut proc); + assert_eq!(result, Ok(2)); // Returns new session id == pid + assert_eq!(proc.sid, 2); + assert_eq!(proc.pgid, 2); + } + + #[test] + fn test_setsid_already_leader_fails() { + let mut proc = Process::new(1); + // First call setsid to become a session leader + let result = sys_setsid(&mut proc); + assert_eq!(result, Ok(1)); + assert_eq!(proc.sid, 1); + // Second call should fail — already a session leader + let result = sys_setsid(&mut proc); + assert_eq!(result, Err(Errno::EPERM)); + } + + // ---- Phase 12: Remaining *at() variants ---- + + #[test] + fn test_faccessat_at_fdcwd() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let mut result = [0u8; 16]; - let err = sys_getaddrinfo(&mut proc, &mut host, b"example.com", &mut result).unwrap_err(); - assert_eq!(err, Errno::ENOENT); + // Should delegate to sys_access + let result = sys_faccessat(&mut proc, &mut host, -100, b"/tmp/test", 0, 0); + assert!(result.is_ok()); } #[test] - fn test_inet_write_after_connect_succeeds() { - // Create a mock that accepts connect and send - struct NetMock; - impl HostIO for NetMock { - fn host_open(&mut self, _p: &[u8], _f: u32, _m: u32) -> Result { - Ok(100) - } - fn host_close(&mut self, _h: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_read(&mut self, _h: i64, _b: &mut [u8]) -> Result { - Ok(0) - } - fn host_write(&mut self, _h: i64, _b: &[u8]) -> Result { - Ok(0) - } - fn host_seek(&mut self, _h: i64, _o: i64, _w: u32) -> Result { - Ok(0) - } - fn host_fstat(&mut self, _h: i64) -> Result { - Ok(unsafe { core::mem::zeroed() }) - } - fn host_stat(&mut self, _p: &[u8]) -> Result { - Ok(unsafe { core::mem::zeroed() }) - } - fn host_lstat(&mut self, _p: &[u8]) -> Result { - Ok(unsafe { core::mem::zeroed() }) - } - fn host_mkdir(&mut self, _p: &[u8], _m: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_rmdir(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_unlink(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_rename(&mut self, _o: &[u8], _n: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_link(&mut self, _e: &[u8], _n: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_symlink(&mut self, _t: &[u8], _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_readlink(&mut self, _p: &[u8], _b: &mut [u8]) -> Result { - Ok(0) - } - fn host_chmod(&mut self, _p: &[u8], _m: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_chown(&mut self, _p: &[u8], _u: u32, _g: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_access(&mut self, _p: &[u8], _m: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_opendir(&mut self, _p: &[u8]) -> Result { - Ok(200) - } - fn host_readdir( - &mut self, - _h: i64, - _n: &mut [u8], - ) -> Result, Errno> { - Ok(None) - } - fn host_closedir(&mut self, _h: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_clock_gettime(&mut self, _c: u32) -> Result<(i64, i64), Errno> { - Ok((0, 0)) - } - fn host_nanosleep(&mut self, _s: i64, _n: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_ftruncate(&mut self, _h: i64, _l: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_fsync(&mut self, _h: i64) -> Result<(), Errno> { - Ok(()) - } - fn host_fchmod(&mut self, _h: i64, _m: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { - Ok(()) - } - fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_set_posix_timer( - &mut self, - _t: i32, - _s: i32, - _v: i64, - _i: i64, - ) -> Result<(), Errno> { - Ok(()) - } - fn host_sigsuspend_wait(&mut self) -> Result { - Err(Errno::EINTR) - } - fn host_call_signal_handler(&mut self, _h: u32, _s: u32, _f: u32) -> Result<(), Errno> { - Ok(()) - } - fn host_getrandom(&mut self, b: &mut [u8]) -> Result { - for x in b.iter_mut() { - *x = 0x42; - } - Ok(b.len()) - } - fn host_utimensat( - &mut self, - _p: &[u8], - _as: i64, - _an: i64, - _ms: i64, - _mn: i64, - ) -> Result<(), Errno> { - Ok(()) - } - fn host_waitpid(&mut self, _p: i32, _o: u32) -> Result<(i32, i32), Errno> { + fn test_faccessat_real_dirfd_invalid() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // fd 5 doesn't exist, should get EBADF + let result = sys_faccessat(&mut proc, &mut host, 5, b"relative", 0, 0); + assert_eq!(result, Err(Errno::EBADF)); + } + + #[test] + fn test_faccessat_absolute_path() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_faccessat(&mut proc, &mut host, 5, b"/absolute", 0, 0); + assert!(result.is_ok()); + } + + #[test] + fn test_fchmodat_at_fdcwd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_fchmodat(&mut proc, &mut host, -100, b"/tmp/test", 0o644, 0); + assert!(result.is_ok()); + } + + #[test] + fn test_fchmodat_real_dirfd_invalid() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // fd 5 doesn't exist, should get EBADF + let result = sys_fchmodat(&mut proc, &mut host, 5, b"relative", 0o644, 0); + assert_eq!(result, Err(Errno::EBADF)); + } + + #[test] + fn test_fchownat_at_fdcwd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_fchownat(&mut proc, &mut host, -100, b"/tmp/test", 1000, 1000, 0); + assert!(result.is_ok()); + } + + #[test] + fn test_fchownat_real_dirfd_invalid() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // fd 5 doesn't exist, should get EBADF + let result = sys_fchownat(&mut proc, &mut host, 5, b"relative", 1000, 1000, 0); + assert_eq!(result, Err(Errno::EBADF)); + } + + #[test] + fn test_linkat_both_fdcwd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_linkat(&mut proc, &mut host, -100, b"/old", -100, b"/new", 0); + assert!(result.is_ok()); + } + + #[test] + fn test_linkat_real_dirfd_invalid() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // fd 5 doesn't exist, should get EBADF + let result = sys_linkat(&mut proc, &mut host, 5, b"old", -100, b"/new", 0); + assert_eq!(result, Err(Errno::EBADF)); + } + + #[test] + fn test_symlinkat_at_fdcwd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_symlinkat(&mut proc, &mut host, b"target", -100, b"/link"); + assert!(result.is_ok()); + } + + #[test] + fn test_symlinkat_real_dirfd_invalid() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // fd 5 doesn't exist, should get EBADF + let result = sys_symlinkat(&mut proc, &mut host, b"target", 5, b"link"); + assert_eq!(result, Err(Errno::EBADF)); + } + + #[test] + fn test_readlinkat_at_fdcwd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_symlink(b"/some/link", b"/target"); + let mut buf = [0u8; 256]; + let result = sys_readlinkat(&mut proc, &mut host, -100, b"/some/link", &mut buf); + // MockHostIO host_readlink returns placeholder data + assert!(result.is_ok()); + } + + #[test] + fn test_readlinkat_real_dirfd_invalid() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 256]; + // fd 5 doesn't exist, should get EBADF + let result = sys_readlinkat(&mut proc, &mut host, 5, b"relative/link", &mut buf); + assert_eq!(result, Err(Errno::EBADF)); + } + + // ---- Phase 12: select() ---- + + #[test] + fn test_select_regular_file_readable() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Open a regular file + let fd = sys_open(&mut proc, &mut host, b"/tmp/test", 0, 0o644).unwrap(); + assert_eq!(fd, 3); + + // Set fd 3 in readfds + let mut readfds = [0u8; 128]; + readfds[0] = 0b1000; // bit 3 + let result = sys_select(&mut proc, &mut host, 4, Some(&mut readfds), None, None, 0); + assert_eq!(result, Ok(1)); + assert_eq!(readfds[0] & 0b1000, 0b1000); // fd 3 still set + } + + #[test] + fn test_select_empty_sets() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_select(&mut proc, &mut host, 0, None, None, None, 0); + assert_eq!(result, Ok(0)); + } + + #[test] + fn test_select_invalid_nfds() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_select(&mut proc, &mut host, -1, None, None, None, 0); + assert_eq!(result, Err(Errno::EINVAL)); + } + + #[test] + fn test_select_pipe_readable() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + + let (rfd, wfd) = sys_pipe(&mut proc).unwrap(); + // Write data to pipe + sys_write(&mut proc, &mut host, wfd, b"hello").unwrap(); + + let mut readfds = [0u8; 128]; + let byte = rfd as usize / 8; + let bit = rfd as usize % 8; + readfds[byte] = 1 << bit; + + let result = sys_select( + &mut proc, + &mut host, + rfd + 1, + Some(&mut readfds), + None, + None, + 0, + ); + assert_eq!(result, Ok(1)); + assert_ne!(readfds[byte] & (1 << bit), 0); + } + + #[test] + fn test_select_pipe_writable() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + + let (_rfd, wfd) = sys_pipe(&mut proc).unwrap(); + + let mut writefds = [0u8; 128]; + let byte = wfd as usize / 8; + let bit = wfd as usize % 8; + writefds[byte] = 1 << bit; + + let result = sys_select( + &mut proc, + &mut host, + wfd + 1, + None, + Some(&mut writefds), + None, + 0, + ); + assert_eq!(result, Ok(1)); + assert_ne!(writefds[byte] & (1 << bit), 0); + } + + #[test] + fn test_select_multiple_fds() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + + let fd1 = sys_open(&mut proc, &mut host, b"/file1", 0, 0o644).unwrap(); + let fd2 = sys_open(&mut proc, &mut host, b"/file2", 0, 0o644).unwrap(); + + let mut readfds = [0u8; 128]; + readfds[fd1 as usize / 8] |= 1 << (fd1 as usize % 8); + readfds[fd2 as usize / 8] |= 1 << (fd2 as usize % 8); + + let max_fd = core::cmp::max(fd1, fd2) + 1; + let result = sys_select( + &mut proc, + &mut host, + max_fd, + Some(&mut readfds), + None, + None, + 0, + ); + assert_eq!(result, Ok(2)); + } + + // ---- Phase 12: setuid/setgid/seteuid/setegid ---- + + #[test] + fn test_setuid_as_root_sets_both() { + let mut proc = Process::new(1); + assert_eq!(proc.uid, 0); + sys_setuid(&mut proc, 42).unwrap(); + assert_eq!(proc.uid, 42); + assert_eq!(proc.euid, 42); + } + + #[test] + fn test_setuid_nonroot_to_own_uid_sets_euid_only() { + let mut proc = Process::new(1); + sys_setuid(&mut proc, 7).unwrap(); // drop to uid=euid=7 + // Simulate regaining privilege partly: impossible without saved-set, + // but setting euid back to real uid is always allowed. + sys_seteuid(&mut proc, 7).unwrap(); + assert_eq!(proc.euid, 7); + } + + #[test] + fn test_setuid_nonroot_to_other_uid_fails() { + let mut proc = Process::new(1); + sys_setuid(&mut proc, 7).unwrap(); + assert_eq!(sys_setuid(&mut proc, 99), Err(Errno::EPERM)); + assert_eq!(proc.uid, 7); + assert_eq!(proc.euid, 7); + } + + #[test] + fn test_setgid_as_root_sets_both() { + let mut proc = Process::new(1); + sys_setgid(&mut proc, 42).unwrap(); + assert_eq!(proc.gid, 42); + assert_eq!(proc.egid, 42); + } + + #[test] + fn test_setgid_nonroot_to_other_gid_fails() { + let mut proc = Process::new(1); + sys_setuid(&mut proc, 7).unwrap(); // drop privilege + assert_eq!(sys_setgid(&mut proc, 99), Err(Errno::EPERM)); + } + + #[test] + fn test_seteuid_as_root_allows_any() { + let mut proc = Process::new(1); + sys_seteuid(&mut proc, 500).unwrap(); + assert_eq!(proc.uid, 0); // real uid unchanged + assert_eq!(proc.euid, 500); + } + + #[test] + fn test_seteuid_nonroot_back_to_ruid() { + let mut proc = Process::new(1); + sys_setuid(&mut proc, 7).unwrap(); + sys_seteuid(&mut proc, 7).unwrap(); + assert_eq!(proc.euid, 7); + } + + #[test] + fn test_seteuid_nonroot_to_other_fails() { + let mut proc = Process::new(1); + sys_setuid(&mut proc, 7).unwrap(); + assert_eq!(sys_seteuid(&mut proc, 99), Err(Errno::EPERM)); + } + + #[test] + fn test_setegid_as_root_allows_any() { + let mut proc = Process::new(1); + sys_setegid(&mut proc, 500).unwrap(); + assert_eq!(proc.gid, 0); + assert_eq!(proc.egid, 500); + } + + #[test] + fn test_can_signal_root_to_anyone() { + assert!(can_signal(0, 0, 1, 1)); + assert!(can_signal(0, 0, 42, 99)); + } + + #[test] + fn test_can_signal_same_user() { + assert!(can_signal(7, 7, 7, 7)); + } + + #[test] + fn test_can_signal_matches_real_or_effective() { + // sender.euid matches target.ruid + assert!(can_signal(99, 7, 7, 0)); + // sender.ruid matches target.euid + assert!(can_signal(7, 99, 0, 7)); + } + + #[test] + fn test_can_signal_denies_cross_user() { + assert!(!can_signal(7, 7, 0, 0)); + assert!(!can_signal(1, 1, 2, 2)); + } + + #[test] + fn test_can_query_sched_root_allowed() { + assert!(can_query_sched(0, 1, 1)); + } + + #[test] + fn test_can_query_sched_euid_match() { + assert!(can_query_sched(7, 7, 7)); + assert!(can_query_sched(7, 7, 99)); + assert!(can_query_sched(7, 99, 7)); + } + + #[test] + fn test_can_query_sched_denies_when_only_ruid_matches() { + // seteuid-only drop: sender ruid still matches target but euid does + // not — POSIX sched_* still denies. + assert!(!can_query_sched(1000, 0, 0)); + assert!(!can_query_sched(99, 7, 7)); + } + + // ---- Phase 12: getrusage ---- + + #[test] + fn test_getrusage_self() { + let mut proc = Process::new(1); + let mut buf = [0xFFu8; wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE as usize]; + let result = sys_getrusage(&mut proc, 0, &mut buf); + assert!(result.is_ok()); + // All fields should be zeroed + assert!(buf.iter().all(|&b| b == 0)); + } + + #[test] + fn test_getrusage_children() { + let mut proc = Process::new(1); + let mut buf = [0xFFu8; wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE as usize]; + let result = sys_getrusage(&mut proc, -1, &mut buf); + assert!(result.is_ok()); + assert!(buf.iter().all(|&b| b == 0)); + } + + #[test] + fn test_getrusage_invalid_who() { + let mut proc = Process::new(1); + let mut buf = [0u8; wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE as usize]; + let result = sys_getrusage(&mut proc, 5, &mut buf); + assert_eq!(result, Err(Errno::EINVAL)); + } + + #[test] + fn test_getrusage_buffer_too_small() { + let mut proc = Process::new(1); + let mut buf = [0u8; 10]; + let result = sys_getrusage(&mut proc, 0, &mut buf); + assert_eq!(result, Err(Errno::EINVAL)); + } + + // ---- realpath ---- + + #[test] + fn test_realpath_absolute() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 256]; + let len = sys_realpath(&mut proc, &mut host, b"/tmp/test", &mut buf).unwrap(); + assert_eq!(&buf[..len], b"/tmp/test"); + } + + #[test] + fn test_realpath_relative() { + let mut proc = Process::new(1); + proc.cwd = b"/home/user".to_vec(); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 256]; + let len = sys_realpath(&mut proc, &mut host, b"file.txt", &mut buf).unwrap(); + assert_eq!(&buf[..len], b"/home/user/file.txt"); + } + + #[test] + fn test_realpath_dotdot() { + let mut proc = Process::new(1); + proc.cwd = b"/home/user".to_vec(); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 256]; + let len = sys_realpath(&mut proc, &mut host, b"../file.txt", &mut buf).unwrap(); + assert_eq!(&buf[..len], b"/home/file.txt"); + } + + #[test] + fn test_realpath_dot() { + let mut proc = Process::new(1); + proc.cwd = b"/home/user".to_vec(); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 256]; + let len = sys_realpath(&mut proc, &mut host, b"./file.txt", &mut buf).unwrap(); + assert_eq!(&buf[..len], b"/home/user/file.txt"); + } + + #[test] + fn test_realpath_empty_path() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 256]; + let result = sys_realpath(&mut proc, &mut host, b"", &mut buf); + assert_eq!(result, Err(Errno::ENOENT)); + } + + #[test] + fn test_realpath_buffer_too_small() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 3]; // Too small for "/tmp/test" + let result = sys_realpath(&mut proc, &mut host, b"/tmp/test", &mut buf); + assert_eq!(result, Err(Errno::ERANGE)); + } + + #[test] + fn test_fork_returns_enosys_with_mock() { + let mut proc = Process::new(1); + let host = MockHostIO::new(); + let result = sys_fork(&mut proc, &host); + assert_eq!(result, Err(Errno::ENOSYS)); + } + + #[test] + fn test_fork_child_fields_default_to_false() { + let proc = Process::new(1); + assert!(!proc.fork_child); + assert!(proc.fork_exec_path.is_none()); + assert!(proc.fork_exec_argv.is_none()); + assert!(proc.fork_fd_actions.is_empty()); + } + + #[test] + fn test_execve_empty_path_returns_enoent() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_execve(&mut proc, &mut host, b""); + assert_eq!(result, Err(Errno::ENOENT)); + } + + #[test] + fn test_execve_delegates_to_host() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_execve(&mut proc, &mut host, b"/bin/ls"); + assert!(result.is_ok()); + } + + #[test] + fn test_alarm_sets_deadline_and_returns_zero_initially() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let remaining = sys_alarm(&mut proc, &mut host, 5).unwrap(); + assert_eq!(remaining, 0); + assert!(proc.alarm_deadline_ns > 0); + } + + #[test] + fn test_alarm_returns_previous_remaining() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_alarm(&mut proc, &mut host, 10).unwrap(); + let remaining = sys_alarm(&mut proc, &mut host, 5).unwrap(); + assert!(remaining > 0 && remaining <= 10); + } + + #[test] + fn test_alarm_zero_cancels() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_alarm(&mut proc, &mut host, 5).unwrap(); + let remaining = sys_alarm(&mut proc, &mut host, 0).unwrap(); + assert!(remaining > 0); + assert_eq!(proc.alarm_deadline_ns, 0); + } + + #[test] + fn test_sigsuspend_returns_eintr_when_signal_already_pending() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.signals.raise(2); // SIGINT + let result = sys_sigsuspend(&mut proc, &mut host, 0); + assert_eq!(result, Err(Errno::EINTR)); + } + + #[test] + fn test_sigsuspend_saves_old_mask_until_signal_dequeue() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.signals.blocked = 0xFF; + proc.signals.raise(2); // SIGINT pending + let result = sys_sigsuspend(&mut proc, &mut host, 0); + let tid = crate::process_table::current_tid(); + assert_eq!(result, Err(Errno::EINTR)); + assert_eq!(proc.signals.blocked, 0); + assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); + } + + #[test] + fn test_sigsuspend_returns_eagain_for_host_retry() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_sigsuspend(&mut proc, &mut host, 0); + assert_eq!(result, Err(Errno::EAGAIN)); + } + + #[test] + fn test_sigsuspend_cannot_block_sigkill() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mask = u64::MAX; // try to block everything + let result = sys_sigsuspend(&mut proc, &mut host, mask); + assert_eq!(result, Err(Errno::EAGAIN)); + assert_eq!(proc.signals.blocked & crate::signal::sig_bit(SIGKILL), 0); + assert_eq!(proc.signals.blocked & crate::signal::sig_bit(SIGSTOP), 0); + } + + #[test] + fn test_sigsuspend_saves_mask_for_host_retry() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.signals.blocked = 0xFF; + let result = sys_sigsuspend(&mut proc, &mut host, 0); + let tid = crate::process_table::current_tid(); + assert_eq!(result, Err(Errno::EAGAIN)); + assert_eq!(proc.signals.blocked, 0); + assert_eq!(proc.sigsuspend_saved_mask_for(tid), Some(0xFF)); + } + + // ---- *at() syscalls with real dirfd ---- + + /// A mock HostIO that records the resolved paths passed to host calls. + #[derive(Debug, Clone, PartialEq, Eq)] + struct BindFbCall { + pid: i32, + addr: usize, + len: usize, + w: u32, + h: u32, + stride: u32, + fmt: u32, + } + + #[derive(Debug, Clone, PartialEq, Eq)] + struct FbWriteCall { + pid: i32, + offset: usize, + len: usize, + } + + struct TrackingHostIO { + next_handle: i64, + last_open_path: Vec, + last_stat_path: Vec, + last_lstat_path: Vec, + last_unlink_path: Vec, + last_rmdir_path: Vec, + last_mkdir_path: Vec, + last_rename_old: Vec, + last_rename_new: Vec, + last_chmod_path: Vec, + last_chown_path: Vec, + last_access_path: Vec, + last_link_old: Vec, + last_link_new: Vec, + last_symlink_target: Vec, + last_symlink_linkpath: Vec, + last_readlink_path: Vec, + bind_framebuffer_calls: Vec, + unbind_framebuffer_calls: Vec, + fb_write_calls: Vec, + } + + impl TrackingHostIO { + fn new() -> Self { + TrackingHostIO { + next_handle: 100, + last_open_path: Vec::new(), + last_stat_path: Vec::new(), + last_lstat_path: Vec::new(), + last_unlink_path: Vec::new(), + last_rmdir_path: Vec::new(), + last_mkdir_path: Vec::new(), + last_rename_old: Vec::new(), + last_rename_new: Vec::new(), + last_chmod_path: Vec::new(), + last_chown_path: Vec::new(), + last_access_path: Vec::new(), + last_link_old: Vec::new(), + last_link_new: Vec::new(), + last_symlink_target: Vec::new(), + last_symlink_linkpath: Vec::new(), + last_readlink_path: Vec::new(), + bind_framebuffer_calls: Vec::new(), + unbind_framebuffer_calls: Vec::new(), + fb_write_calls: Vec::new(), + } + } + } + + impl HostIO for TrackingHostIO { + fn host_open(&mut self, path: &[u8], _flags: u32, _mode: u32) -> Result { + self.last_open_path = path.to_vec(); + let h = self.next_handle; + self.next_handle += 1; + Ok(h) + } + fn host_close(&mut self, _handle: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_read(&mut self, _handle: i64, buf: &mut [u8]) -> Result { + let n = buf.len().min(5); + buf[..n].copy_from_slice(&b"hello"[..n]); + Ok(n) + } + fn host_write(&mut self, _handle: i64, buf: &[u8]) -> Result { + Ok(buf.len()) + } + fn host_seek(&mut self, _handle: i64, _offset: i64, _whence: u32) -> Result { + Ok(0) + } + fn host_fstat(&mut self, _handle: i64) -> Result { + Ok(WasmStat { + st_dev: 0, + st_ino: 0, + st_mode: S_IFREG | 0o644, + st_nlink: 1, + st_uid: 0, + st_gid: 0, + st_size: 1024, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }) + } + fn host_stat(&mut self, path: &[u8]) -> Result { + self.last_stat_path = path.to_vec(); + let mode = test_default_mode(path); + Ok(WasmStat { + st_dev: 0, + st_ino: 1, + st_mode: mode, + st_nlink: 1, + st_uid: 0, + st_gid: 0, + st_size: 1024, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }) + } + fn host_lstat(&mut self, path: &[u8]) -> Result { + self.last_lstat_path = path.to_vec(); + // Return regular file/dir by default (not symlink) so realpath works + let mode = test_default_mode(path); + Ok(WasmStat { + st_dev: 0, + st_ino: 2, + st_mode: mode, + st_nlink: 1, + st_uid: 0, + st_gid: 0, + st_size: 1024, + st_atime_sec: 0, + st_atime_nsec: 0, + st_mtime_sec: 0, + st_mtime_nsec: 0, + st_ctime_sec: 0, + st_ctime_nsec: 0, + _pad: 0, + }) + } + fn host_mkdir(&mut self, path: &[u8], _mode: u32) -> Result<(), Errno> { + self.last_mkdir_path = path.to_vec(); + Ok(()) + } + fn host_rmdir(&mut self, path: &[u8]) -> Result<(), Errno> { + self.last_rmdir_path = path.to_vec(); + Ok(()) + } + fn host_unlink(&mut self, path: &[u8]) -> Result<(), Errno> { + self.last_unlink_path = path.to_vec(); + Ok(()) + } + fn host_rename(&mut self, oldpath: &[u8], newpath: &[u8]) -> Result<(), Errno> { + self.last_rename_old = oldpath.to_vec(); + self.last_rename_new = newpath.to_vec(); + Ok(()) + } + fn host_link(&mut self, oldpath: &[u8], newpath: &[u8]) -> Result<(), Errno> { + self.last_link_old = oldpath.to_vec(); + self.last_link_new = newpath.to_vec(); + Ok(()) + } + fn host_symlink(&mut self, target: &[u8], linkpath: &[u8]) -> Result<(), Errno> { + self.last_symlink_target = target.to_vec(); + self.last_symlink_linkpath = linkpath.to_vec(); + Ok(()) + } + fn host_readlink(&mut self, path: &[u8], buf: &mut [u8]) -> Result { + self.last_readlink_path = path.to_vec(); + let target = b"/target"; + let n = buf.len().min(target.len()); + buf[..n].copy_from_slice(&target[..n]); + Ok(n) + } + fn host_chmod(&mut self, path: &[u8], _mode: u32) -> Result<(), Errno> { + self.last_chmod_path = path.to_vec(); + Ok(()) + } + fn host_chown(&mut self, path: &[u8], _uid: u32, _gid: u32) -> Result<(), Errno> { + self.last_chown_path = path.to_vec(); + Ok(()) + } + fn host_access(&mut self, path: &[u8], _amode: u32) -> Result<(), Errno> { + self.last_access_path = path.to_vec(); + Ok(()) + } + fn host_opendir(&mut self, _path: &[u8]) -> Result { + Ok(200) + } + fn host_readdir( + &mut self, + _handle: i64, + _name_buf: &mut [u8], + ) -> Result, Errno> { + Ok(None) + } + fn host_closedir(&mut self, _handle: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_clock_gettime(&mut self, _clock_id: u32) -> Result<(i64, i64), Errno> { + Ok((0, 0)) + } + fn host_nanosleep(&mut self, _seconds: i64, _nanoseconds: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_ftruncate(&mut self, _handle: i64, _length: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_fsync(&mut self, _handle: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_fchmod(&mut self, _handle: i64, _mode: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_fchown(&mut self, _handle: i64, _uid: u32, _gid: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_kill(&mut self, _pid: i32, _sig: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_exec(&mut self, _path: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_set_alarm(&mut self, _seconds: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_set_posix_timer( + &mut self, + _timer_id: i32, + _signo: i32, + _value_ms: i64, + _interval_ms: i64, + ) -> Result<(), Errno> { + Ok(()) + } + fn host_sigsuspend_wait(&mut self) -> Result { + Err(Errno::EINTR) + } + fn host_call_signal_handler( + &mut self, + _handler_index: u32, + _signum: u32, + _sa_flags: u32, + ) -> Result<(), Errno> { + Ok(()) + } + fn host_getrandom(&mut self, buf: &mut [u8]) -> Result { + for (i, b) in buf.iter_mut().enumerate() { + *b = (i & 0xFF) as u8; + } + Ok(buf.len()) + } + fn host_utimensat( + &mut self, + _path: &[u8], + _atime_sec: i64, + _atime_nsec: i64, + _mtime_sec: i64, + _mtime_nsec: i64, + ) -> Result<(), Errno> { + Ok(()) + } + fn host_waitpid(&mut self, _pid: i32, _options: u32) -> Result<(i32, i32), Errno> { + Err(Errno::ECHILD) + } + fn host_net_connect( + &mut self, + _handle: i32, + _addr: &[u8], + _port: u16, + ) -> Result<(), Errno> { + Err(Errno::ECONNREFUSED) + } + fn host_net_connect_status(&mut self, _handle: i32) -> Result<(), Errno> { + Err(Errno::ECONNREFUSED) + } + fn host_net_send( + &mut self, + _handle: i32, + _data: &[u8], + _flags: u32, + ) -> Result { + Err(Errno::ENOTCONN) + } + fn host_net_recv( + &mut self, + _handle: i32, + _len: u32, + _flags: u32, + _buf: &mut [u8], + ) -> Result { + Err(Errno::ENOTCONN) + } + fn host_net_close(&mut self, _handle: i32) -> Result<(), Errno> { + Ok(()) + } + fn host_net_listen(&mut self, _fd: i32, _port: u16, _addr: &[u8; 4]) -> Result<(), Errno> { + Ok(()) + } + fn host_getaddrinfo(&mut self, _name: &[u8], _result: &mut [u8]) -> Result { + Err(Errno::ENOENT) + } + fn host_fcntl_lock( + &mut self, + _path: &[u8], + _pid: u32, + _cmd: u32, + _lock_type: u32, + _start: i64, + _len: i64, + _result_buf: &mut [u8], + ) -> Result<(), Errno> { + Ok(()) + } + fn host_fork(&self) -> i32 { + -(Errno::ENOSYS as i32) + } + fn host_futex_wait( + &mut self, + _addr: usize, + _expected: u32, + _timeout_ns: i64, + ) -> Result { + Err(Errno::EAGAIN) + } + fn host_futex_wake(&mut self, _addr: usize, _count: u32) -> Result { + Ok(0) + } + fn host_clone( + &mut self, + _fn_ptr: usize, + _arg: usize, + _stack_ptr: usize, + _tls_ptr: usize, + _ctid_ptr: usize, + ) -> Result { + Err(Errno::ENOSYS) + } + fn bind_framebuffer( + &mut self, + pid: i32, + addr: usize, + len: usize, + w: u32, + h: u32, + stride: u32, + fmt: u32, + ) { + self.bind_framebuffer_calls.push(BindFbCall { + pid, + addr, + len, + w, + h, + stride, + fmt, + }); + } + fn unbind_framebuffer(&mut self, pid: i32) { + self.unbind_framebuffer_calls.push(pid); + } + fn fb_write(&mut self, pid: i32, offset: usize, bytes: &[u8]) { + self.fb_write_calls.push(FbWriteCall { + pid, + offset, + len: bytes.len(), + }); + } + } + + /// Helper: open a directory and return its fd. + fn open_dir_fd(proc: &mut Process, host: &mut dyn HostIO, dir_path: &[u8]) -> i32 { + sys_open(proc, host, dir_path, O_RDONLY | O_DIRECTORY, 0).expect("open dir should succeed") + } + + #[test] + fn test_openat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/home/user/dir"); + // open a file relative to /home/user/dir + let fd = sys_openat(&mut proc, &mut host, dirfd, b"file.txt", O_RDONLY, 0); + assert!(fd.is_ok()); + assert_eq!(host.last_open_path, b"/home/user/dir/file.txt"); + } + + #[test] + fn test_fstatat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); + let result = sys_fstatat(&mut proc, &mut host, dirfd, b"subdir", 0); + assert!(result.is_ok()); + assert_eq!(host.last_stat_path, b"/var/dir/subdir"); + } + + #[test] + fn test_fstatat_with_real_dirfd_nofollow() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); + let result = sys_fstatat(&mut proc, &mut host, dirfd, b"link", AT_SYMLINK_NOFOLLOW); + assert!(result.is_ok()); + assert_eq!(host.last_lstat_path, b"/var/dir/link"); + } + + #[test] + fn test_unlinkat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/tmp/dir"); + let result = sys_unlinkat(&mut proc, &mut host, dirfd, b"file", 0); + assert!(result.is_ok()); + assert_eq!(host.last_unlink_path, b"/tmp/dir/file"); + } + + #[test] + fn test_unlinkat_removedir_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/tmp/dir"); + let result = sys_unlinkat(&mut proc, &mut host, dirfd, b"subdir", AT_REMOVEDIR); + assert!(result.is_ok()); + assert_eq!(host.last_rmdir_path, b"/tmp/dir/subdir"); + } + + #[test] + fn test_mkdirat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/home/dir"); + let result = sys_mkdirat(&mut proc, &mut host, dirfd, b"newdir", 0o755); + assert!(result.is_ok()); + assert_eq!(host.last_mkdir_path, b"/home/dir/newdir"); + } + + #[test] + fn test_renameat_with_real_dirfds() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd1 = open_dir_fd(&mut proc, &mut host, b"/src/dir"); + let dirfd2 = open_dir_fd(&mut proc, &mut host, b"/dst/dir"); + let result = sys_renameat(&mut proc, &mut host, dirfd1, b"old", dirfd2, b"new"); + assert!(result.is_ok()); + assert_eq!(host.last_rename_old, b"/src/dir/old"); + assert_eq!(host.last_rename_new, b"/dst/dir/new"); + } + + #[test] + fn test_linkat_with_real_dirfds() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/data/dir"); + let result = sys_linkat(&mut proc, &mut host, dirfd, b"existing", dirfd, b"link", 0); + assert!(result.is_ok()); + assert_eq!(host.last_link_old, b"/data/dir/existing"); + assert_eq!(host.last_link_new, b"/data/dir/link"); + } + + #[test] + fn test_symlinkat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/opt/dir"); + let result = sys_symlinkat(&mut proc, &mut host, b"../target", dirfd, b"link"); + assert!(result.is_ok()); + // target is stored as-is, only linkpath is resolved + assert_eq!(host.last_symlink_target, b"../target"); + assert_eq!(host.last_symlink_linkpath, b"/opt/dir/link"); + } + + #[test] + fn test_readlinkat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/etc/dir"); + let mut buf = [0u8; 256]; + let result = sys_readlinkat(&mut proc, &mut host, dirfd, b"link", &mut buf); + assert!(result.is_ok()); + assert_eq!(host.last_readlink_path, b"/etc/dir/link"); + } + + #[test] + fn test_fchmodat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); + let result = sys_fchmodat(&mut proc, &mut host, dirfd, b"file", 0o644, 0); + assert!(result.is_ok()); + assert_eq!(host.last_chmod_path, b"/var/dir/file"); + } + + #[test] + fn test_fchownat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); + let result = sys_fchownat(&mut proc, &mut host, dirfd, b"file", 1000, 1000, 0); + assert!(result.is_ok()); + assert_eq!(host.last_chown_path, b"/var/dir/file"); + } + + #[test] + fn test_faccessat_with_real_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/var/dir"); + let result = sys_faccessat(&mut proc, &mut host, dirfd, b"file", 0, 0); + assert!(result.is_ok()); + assert_eq!(host.last_lstat_path, b"/var/dir/file"); + } + + #[test] + fn test_at_absolute_path_ignores_dirfd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/wrong/dir"); + // Absolute path should ignore dirfd + let result = sys_fstatat(&mut proc, &mut host, dirfd, b"/absolute/path", 0); + assert!(result.is_ok()); + assert_eq!(host.last_stat_path, b"/absolute/path"); + } + + #[test] + fn test_at_enotdir_when_dirfd_is_regular_file() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + // Open a regular file (not a directory) + let fd = sys_open(&mut proc, &mut host, b"/some/file.txt", O_RDONLY, 0).unwrap(); + // Try to use it as a dirfd - should get ENOTDIR + let result = sys_fstatat(&mut proc, &mut host, fd, b"relative", 0); + assert!(matches!(result, Err(Errno::ENOTDIR))); + } + + #[test] + fn test_openat_stores_resolved_path_in_ofd() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let dirfd = open_dir_fd(&mut proc, &mut host, b"/base/dir"); + let fd = sys_openat( + &mut proc, + &mut host, + dirfd, + b"child/dir", + O_RDONLY | O_DIRECTORY, + 0, + ) + .unwrap(); + // The new OFD should have the resolved path stored + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!(ofd.path, b"/base/dir/child/dir"); + } + + #[test] + fn test_inet_socket_creation() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + assert!(fd >= 0); + } + + #[test] + fn test_inet_connect_returns_econnrefused_with_mock() { + // MockHostIO returns ECONNREFUSED for host_net_connect + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + // sockaddr_in: AF_INET(2) + port(80) + IP(127.0.0.1) + let addr = [2, 0, 0, 80, 127, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0]; + let err = sys_connect(&mut proc, &mut host, fd, &addr).unwrap_err(); + assert_eq!(err, Errno::ECONNREFUSED); + } + + #[test] + fn test_inet_send_on_unconnected_returns_enotconn() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let err = sys_send(&mut proc, &mut host, fd, b"hello", 0).unwrap_err(); + assert_eq!(err, Errno::ENOTCONN); + } + + #[test] + fn test_unix_socket_connect_still_returns_econnrefused() { + // AF_UNIX SOCK_STREAM connect to nonexistent path should fail with ECONNREFUSED + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; // AF_UNIX + let path = b"/tmp/noexist.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + let err = sys_connect(&mut proc, &mut host, fd, &addr[..addrlen]).unwrap_err(); + assert_eq!(err, Errno::ECONNREFUSED); + } + + #[test] + fn test_unix_dgram_connect_missing_peer_is_econnrefused() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut addr = [0u8; 64]; + addr[0] = 1; + let path = b"/tmp/no-dgram-peer.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + assert_eq!( + sys_connect(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap_err(), + Errno::ECONNREFUSED, + ); + } + + #[test] + fn test_unix_stream_connect_same_process() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9001); + let mut host = MockHostIO::new(); + let path = b"/tmp/connect_9001.sock"; + // Clean up any stale registration from a prior test run + let resolved = crate::path::resolve_path(path, &proc.cwd); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + // Create and bind a listener + let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); // AF_UNIX, SOCK_STREAM + let mut addr = [0u8; 110]; + addr[0] = 1; // AF_UNIX + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + // Connect a client + let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); + + // Client should be connected + let entry = proc.fd_table.get(client_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(sock.state, crate::socket::SocketState::Connected); + + // Server should have a pending connection + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + assert!(accepted_fd >= 0); + + // Lazily materializing the accepted socket must restore process-local + // peer identity when the client and acceptor are still the same + // process, so MSG_OOB retains its existing semantics. + sys_send( + &mut proc, + &mut host, + client_fd, + b"X", + wasm_posix_shared::socket::MSG_OOB, + ) + .unwrap(); + let mut oob = [0u8; 1]; + assert_eq!( + sys_recv( + &mut proc, + &mut host, + accepted_fd, + &mut oob, + wasm_posix_shared::socket::MSG_OOB, + ) + .unwrap(), + 1, + ); + assert_eq!(oob, [b'X']); + + // Clean up + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + } + + #[test] + fn test_fork_child_accepts_parent_unix_listener_queue() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + use crate::process_table::ProcessTable; + + const PARENT: u32 = 9031; + const CHILD: u32 = 9032; + let path = b"/tmp/fork_accept_9031.sock"; + let resolved = crate::path::resolve_path(path, b"/"); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let mut host = MockHostIO::new(); + let mut parent = Process::new(PARENT); + let server_fd = sys_socket(&mut parent, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut parent, &mut host, server_fd, &addr[..addrlen]).unwrap(); + sys_listen(&mut parent, &mut host, server_fd, 5).unwrap(); + + let mut table = ProcessTable::new(); + table.processes.insert(PARENT, parent); + table.fork_process(PARENT, CHILD).unwrap(); + + let client_fd = { + let parent = table.get_mut(PARENT).unwrap(); + let fd = sys_socket(parent, &mut host, 1, 1, 0).unwrap(); + sys_connect(parent, &mut host, fd, &addr[..addrlen]).unwrap(); + fd + }; + let accepted_fd = { + let child = table.get_mut(CHILD).unwrap(); + sys_accept(child, &mut host, server_fd).unwrap() + }; + + assert_eq!( + sys_send( + table.get_mut(PARENT).unwrap(), + &mut host, + client_fd, + b"shared queue", + 0, + ) + .unwrap(), + 12, + ); + let mut buf = [0u8; 16]; + let received = sys_recv( + table.get_mut(CHILD).unwrap(), + &mut host, + accepted_fd, + &mut buf, + 0, + ) + .unwrap(); + assert_eq!(&buf[..received], b"shared queue"); + + { + let child = table.get_mut(CHILD).unwrap(); + sys_close(child, &mut host, accepted_fd).unwrap(); + sys_close(child, &mut host, server_fd).unwrap(); + } + { + let parent = table.get_mut(PARENT).unwrap(); + sys_close(parent, &mut host, client_fd).unwrap(); + sys_close(parent, &mut host, server_fd).unwrap(); + } + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + } + + #[test] + fn test_unix_stream_connect_pushes_accept_wakeup() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9012); + let mut host = MockHostIO::new(); + let path = b"/tmp/wakeup_9012.sock"; + let resolved = crate::path::resolve_path(path, &proc.cwd); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; // AF_UNIX + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + let accept_idx = { + let entry = proc.fd_table.get(server_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let sock_idx = (-(ofd.host_handle + 1)) as usize; + proc.sockets.get(sock_idx).unwrap().accept_wake_idx.unwrap() + }; + + let mut wake_buf = [0u8; 16]; + crate::wakeup::drain(&mut wake_buf, 8); + + let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); + + let count = crate::wakeup::drain(&mut wake_buf, 8); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + assert_eq!(count, 1); + assert_eq!( + u32::from_le_bytes([wake_buf[0], wake_buf[1], wake_buf[2], wake_buf[3]]), + accept_idx + ); + assert_eq!(wake_buf[4], crate::wakeup::WAKE_ACCEPT); + } + + #[test] + fn test_unix_stream_connect_no_listener() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9002); + let mut host = MockHostIO::new(); + let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; // AF_UNIX + let path = b"/tmp/noexist_9002.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + let err = sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap_err(); + assert_eq!(err, Errno::ECONNREFUSED); + } + + #[test] + fn test_unix_stream_bidirectional_data() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9003); + let mut host = MockHostIO::new(); + let path = b"/tmp/bidir_9003.sock"; + // Clean up any stale registration from a prior test run + let resolved = crate::path::resolve_path(path, &proc.cwd); + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + + // Set up listener + let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + // Connect client + let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + + // Client sends, server receives + let msg = b"hello from client"; + let sent = sys_send(&mut proc, &mut host, client_fd, msg, 0).unwrap(); + assert_eq!(sent, msg.len()); + let mut buf = [0u8; 64]; + let recvd = sys_recv(&mut proc, &mut host, accepted_fd, &mut buf, 0).unwrap(); + assert_eq!(&buf[..recvd], msg); + + // Server sends, client receives + let reply = b"hello from server"; + let sent = sys_send(&mut proc, &mut host, accepted_fd, reply, 0).unwrap(); + assert_eq!(sent, reply.len()); + let recvd = sys_recv(&mut proc, &mut host, client_fd, &mut buf, 0).unwrap(); + assert_eq!(&buf[..recvd], reply); + + // Clean up + unsafe { crate::unix_socket::global_unix_socket_registry() }.unregister(&resolved); + } + + #[test] + fn test_stat_unix_socket_path() { + use wasm_posix_shared::flags::AT_FDCWD; + use wasm_posix_shared::mode::{S_IFMT, S_IFSOCK}; + + fn assert_socket_metadata(st: WasmStat, mode: u32, uid: u32, gid: u32) { + assert_eq!(st.st_mode & S_IFMT, S_IFSOCK); + assert_eq!(st.st_mode & 0o7777, mode); + assert_eq!(st.st_uid, uid); + assert_eq!(st.st_gid, gid); + } + + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.pid = 9020; + proc.umask = 0o027; + let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; + let path = b"/tmp/stat.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); + + assert_socket_metadata( + sys_stat(&mut proc, &mut host, path).unwrap(), + 0o750, + 0, + 0, + ); + assert_socket_metadata( + sys_lstat(&mut proc, &mut host, path).unwrap(), + 0o750, + 0, + 0, + ); + assert_socket_metadata( + sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(), + 0o750, + 0, + 0, + ); + + sys_chmod(&mut proc, &mut host, path, 0o640).unwrap(); + sys_chown(&mut proc, &mut host, b"/tmp/stat.sock", 1234, 5678).unwrap(); + assert_socket_metadata( + sys_stat(&mut proc, &mut host, path).unwrap(), + 0o640, + 1234, + 5678, + ); + assert_socket_metadata( + sys_lstat(&mut proc, &mut host, path).unwrap(), + 0o640, + 1234, + 5678, + ); + assert_socket_metadata( + sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap(), + 0o640, + 1234, + 5678, + ); + + // Cleanup + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.cleanup_process(9020); + } + + #[test] + fn test_unlink_unix_socket_path() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.pid = 9021; + let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; + let path = b"/tmp/unlink.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); + + // Socket path should exist + assert!(sys_stat(&mut proc, &mut host, b"/tmp/unlink.sock").is_ok()); + + // Unlink removes the path from registry + sys_unlink(&mut proc, &mut host, b"/tmp/unlink.sock").unwrap(); + + // Another socket can now bind to the same path + let fd2 = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + sys_bind(&mut proc, &mut host, fd2, &addr[..2 + path.len() + 1]).unwrap(); + + // Cleanup + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.cleanup_process(9021); + } + + #[test] + fn test_fstat_socket_fd() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let st = sys_fstat(&mut proc, &mut host, fd).unwrap(); + assert_eq!( + st.st_mode & wasm_posix_shared::mode::S_IFMT, + wasm_posix_shared::mode::S_IFSOCK + ); + } + + #[test] + fn test_getsockname_unix_with_path() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.pid = 9022; + let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 110]; + addr[0] = 1; + let path = b"/tmp/getsockname.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + sys_bind(&mut proc, &mut host, fd, &addr[..2 + path.len() + 1]).unwrap(); + + let mut buf = [0u8; 128]; + let n = sys_getsockname(&proc, fd, &mut buf).unwrap(); + assert_eq!(buf[0], 1); // AF_UNIX + assert!(n >= 2 + path.len()); + assert_eq!(&buf[2..2 + path.len()], path); + + // Cleanup + let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; + registry.cleanup_process(9022); + } + + #[test] + fn test_abstract_unix_socket_bind_is_not_filesystem_backed() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.pid = 9023; + let fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 1; // AF_UNIX + addr[2] = 0; // Linux abstract namespace marker + addr[3..8].copy_from_slice(b"abs01"); + let addrlen = 8; + + sys_bind(&mut proc, &mut host, fd, &addr[..addrlen]).unwrap(); + assert_eq!( + host.next_handle, 100, + "abstract AF_UNIX bind must not create a host filesystem inode", + ); + + let mut name = [0u8; 32]; + let n = sys_getsockname(&proc, fd, &mut name).unwrap(); + assert_eq!(n, addrlen); + assert_eq!(&name[..addrlen], &addr[..addrlen]); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9023); + } + + #[test] + fn test_abstract_unix_socket_same_process_connect() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.pid = 9024; + let server_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 1; // AF_UNIX + addr[2] = 0; // abstract namespace + addr[3..9].copy_from_slice(b"abs002"); + let addrlen = 9; + sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + let client_fd = sys_socket(&mut proc, &mut host, 1, 1, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"abstract").unwrap(), + 8, + ); + let mut buf = [0u8; 16]; + let n = sys_read(&mut proc, &mut host, accepted_fd, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"abstract"); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9024); + } + + #[test] + fn test_clock_getres_per_process_cpu_clock() { + let proc = Process::new(1); + // clock_getcpuclockid(pid) encodes as (-pid-1)*8 + 2 + // For pid=1: (-1-1)*8 + 2 = -16 + 2 = -14, as u32 = 4294967282 + let clock_id = ((-1i32 - 1) * 8 + 2) as u32; + let res = sys_clock_getres(&proc, clock_id).unwrap(); + assert_eq!(res.tv_sec, 0); + assert_eq!(res.tv_nsec, 1_000_000); + } + + #[test] + fn test_getaddrinfo_returns_enoent_with_mock() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mut result = [0u8; 16]; + let err = sys_getaddrinfo(&mut proc, &mut host, b"example.com", &mut result).unwrap_err(); + assert_eq!(err, Errno::ENOENT); + } + + #[test] + fn test_inet_write_after_connect_succeeds() { + // Create a mock that accepts connect and send + struct NetMock; + impl HostIO for NetMock { + fn host_open(&mut self, _p: &[u8], _f: u32, _m: u32) -> Result { + Ok(100) + } + fn host_close(&mut self, _h: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_read(&mut self, _h: i64, _b: &mut [u8]) -> Result { + Ok(0) + } + fn host_write(&mut self, _h: i64, _b: &[u8]) -> Result { + Ok(0) + } + fn host_seek(&mut self, _h: i64, _o: i64, _w: u32) -> Result { + Ok(0) + } + fn host_fstat(&mut self, _h: i64) -> Result { + Ok(unsafe { core::mem::zeroed() }) + } + fn host_stat(&mut self, _p: &[u8]) -> Result { + Ok(unsafe { core::mem::zeroed() }) + } + fn host_lstat(&mut self, _p: &[u8]) -> Result { + Ok(unsafe { core::mem::zeroed() }) + } + fn host_mkdir(&mut self, _p: &[u8], _m: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_rmdir(&mut self, _p: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_unlink(&mut self, _p: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_rename(&mut self, _o: &[u8], _n: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_link(&mut self, _e: &[u8], _n: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_symlink(&mut self, _t: &[u8], _p: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_readlink(&mut self, _p: &[u8], _b: &mut [u8]) -> Result { + Ok(0) + } + fn host_chmod(&mut self, _p: &[u8], _m: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_chown(&mut self, _p: &[u8], _u: u32, _g: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_access(&mut self, _p: &[u8], _m: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_opendir(&mut self, _p: &[u8]) -> Result { + Ok(200) + } + fn host_readdir( + &mut self, + _h: i64, + _n: &mut [u8], + ) -> Result, Errno> { + Ok(None) + } + fn host_closedir(&mut self, _h: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_clock_gettime(&mut self, _c: u32) -> Result<(i64, i64), Errno> { + Ok((0, 0)) + } + fn host_nanosleep(&mut self, _s: i64, _n: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_ftruncate(&mut self, _h: i64, _l: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_fsync(&mut self, _h: i64) -> Result<(), Errno> { + Ok(()) + } + fn host_fchmod(&mut self, _h: i64, _m: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_fchown(&mut self, _h: i64, _u: u32, _g: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_kill(&mut self, _p: i32, _s: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_exec(&mut self, _p: &[u8]) -> Result<(), Errno> { + Ok(()) + } + fn host_set_alarm(&mut self, _s: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_set_posix_timer( + &mut self, + _t: i32, + _s: i32, + _v: i64, + _i: i64, + ) -> Result<(), Errno> { + Ok(()) + } + fn host_sigsuspend_wait(&mut self) -> Result { + Err(Errno::EINTR) + } + fn host_call_signal_handler(&mut self, _h: u32, _s: u32, _f: u32) -> Result<(), Errno> { + Ok(()) + } + fn host_getrandom(&mut self, b: &mut [u8]) -> Result { + for x in b.iter_mut() { + *x = 0x42; + } + Ok(b.len()) + } + fn host_utimensat( + &mut self, + _p: &[u8], + _as: i64, + _an: i64, + _ms: i64, + _mn: i64, + ) -> Result<(), Errno> { + Ok(()) + } + fn host_waitpid(&mut self, _p: i32, _o: u32) -> Result<(i32, i32), Errno> { Err(Errno::ECHILD) } fn host_net_connect(&mut self, _h: i32, _a: &[u8], _p: u16) -> Result<(), Errno> { @@ -16995,666 +23274,2428 @@ mod tests { fn fb_write(&mut self, _pid: i32, _offset: usize, _bytes: &[u8]) {} } - let mut proc = Process::new(1); - let mut host = NetMock; - use wasm_posix_shared::socket::*; + let mut proc = Process::new(1); + let mut host = NetMock; + use wasm_posix_shared::socket::*; + + // Create socket + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + + // Connect to a non-loopback address to test host delegation + let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + // Write should succeed (delegating to host_net_send) + let result = sys_write(&mut proc, &mut host, fd, b"hello world"); + assert!( + result.is_ok(), + "sys_write on connected AF_INET socket should succeed, got: {:?}", + result + ); + assert_eq!(result.unwrap(), 11); + + // Read should succeed (delegating to host_net_recv, returns 0 = EOF from mock) + let mut buf = [0u8; 64]; + let result = sys_read(&mut proc, &mut host, fd, &mut buf); + assert!( + result.is_ok(), + "sys_read on connected AF_INET socket should succeed, got: {:?}", + result + ); + assert_eq!(result.unwrap(), 0); + } + + // ===== setitimer / getitimer tests ===== + + #[test] + fn test_getitimer_initial_zero() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_getitimer(&mut proc, &mut host, 0).unwrap(); + assert_eq!(result, (0, 0, 0, 0)); + } + + #[test] + fn test_getitimer_virtual_always_zero() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_getitimer(&mut proc, &mut host, 1).unwrap(); + assert_eq!(result, (0, 0, 0, 0)); + } + + #[test] + fn test_getitimer_invalid_which() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_getitimer(&mut proc, &mut host, 3); + assert_eq!(result, Err(Errno::EINVAL)); + } + + #[test] + fn test_setitimer_cancel() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Set an alarm first + proc.alarm_deadline_ns = 1_000_000_000; + proc.alarm_interval_ns = 500_000_000; + // Cancel it + let old = sys_setitimer(&mut proc, &mut host, 0, 0, 0, 0, 0).unwrap(); + // Old should have had interval 0.5s + assert_eq!(old.0, 0); // interval_sec + assert_eq!(old.1, 500000); // interval_usec + // Now timer should be cleared + assert_eq!(proc.alarm_deadline_ns, 0); + assert_eq!(proc.alarm_interval_ns, 0); + } + + #[test] + fn test_setitimer_sets_alarm() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // MockHostIO returns time (1234567890, 123456789) + let now_ns = 1234567890u64 * 1_000_000_000 + 123456789u64; + // Set a 2.5 second timer with 1 second interval + let result = sys_setitimer(&mut proc, &mut host, 0, 1, 0, 2, 500000).unwrap(); + // Old was zero + assert_eq!(result, (0, 0, 0, 0)); + // Interval should be stored + assert_eq!(proc.alarm_interval_ns, 1_000_000_000); + // Deadline should be now_ns + 2.5s + assert_eq!(proc.alarm_deadline_ns, now_ns + 2_500_000_000); + } + + #[test] + fn test_setitimer_virtual_noop() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_setitimer(&mut proc, &mut host, 1, 1, 0, 1, 0).unwrap(); + assert_eq!(result, (0, 0, 0, 0)); + // ITIMER_VIRTUAL doesn't affect alarm_deadline_ns + assert_eq!(proc.alarm_deadline_ns, 0); + } + + // ===== sigtimedwait tests ===== + + #[test] + fn test_sigtimedwait_pending_signal() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Raise SIGUSR1 (signal 10) + proc.signals.pending |= crate::signal::sig_bit(10); + // Wait for SIGUSR1 + let mask = crate::signal::sig_bit(10); + let (sig, ..) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); + assert_eq!(sig, 10); + // Signal should be dequeued + assert_eq!(proc.signals.pending & crate::signal::sig_bit(10), 0); + } + + #[test] + fn test_sigtimedwait_no_pending_timeout_zero() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mask = crate::signal::sig_bit(10); + let result = sys_sigtimedwait(&mut proc, &mut host, mask, 0); + assert_eq!(result, Err(Errno::EAGAIN)); + } + + #[test] + fn test_sigtimedwait_dequeues_lowest() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Raise both SIGUSR1 (10) and SIGUSR2 (12) + proc.signals.pending |= crate::signal::sig_bit(10) | crate::signal::sig_bit(12); + let mask = crate::signal::sig_bit(10) | crate::signal::sig_bit(12); + let (sig, ..) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); + assert_eq!(sig, 10); // lowest first + // Only SIGUSR1 should be dequeued + assert_eq!(proc.signals.pending & crate::signal::sig_bit(10), 0); + assert_ne!(proc.signals.pending & crate::signal::sig_bit(12), 0); + } + + #[test] + fn test_sigtimedwait_rt_queued_multiple() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // Queue SIGRTMIN (32) three times + proc.signals.raise(32); + proc.signals.raise(32); + proc.signals.raise(32); + let mask = crate::signal::sig_bit(32); + // First dequeue should return signal 32 and leave 2 queued + let (s1, ..) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); + assert_eq!(s1, 32); + assert!( + proc.signals.is_pending(32), + "should still be pending with 2 queued" + ); + // Second + let (s2, ..) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); + assert_eq!(s2, 32); + assert!( + proc.signals.is_pending(32), + "should still be pending with 1 queued" + ); + // Third + let (s3, ..) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); + assert_eq!(s3, 32); + assert!(!proc.signals.is_pending(32), "should no longer be pending"); + // Fourth should fail + let r4 = sys_sigtimedwait(&mut proc, &mut host, mask, 0); + assert_eq!(r4, Err(Errno::EAGAIN)); + } + + // ===== preadv / pwritev tests ===== + + #[test] + fn test_preadv_basic() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/test/file", O_RDONLY, 0).unwrap(); + let mut buf1 = [0u8; 4]; + let mut buf2 = [0u8; 4]; + let mut iovecs: [&mut [u8]; 2] = [&mut buf1, &mut buf2]; + // TrackingHostIO reads return 0 (EOF), so total should be 0 + let result = sys_preadv(&mut proc, &mut host, fd, &mut iovecs, 0); + assert!(result.is_ok()); + } + + #[test] + fn test_preadv_rejects_pipe() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); + let mut buf = [0u8; 4]; + let mut iovecs: [&mut [u8]; 1] = [&mut buf]; + let result = sys_preadv(&mut proc, &mut host, read_fd, &mut iovecs, 0); + assert_eq!(result, Err(Errno::ESPIPE)); + } + + #[test] + fn test_pwritev_rejects_pipe() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); + let iovecs: [&[u8]; 1] = [b"hello"]; + let result = sys_pwritev(&mut proc, &mut host, write_fd, &iovecs, 0); + assert_eq!(result, Err(Errno::ESPIPE)); + } + + // ===== sendfile tests ===== + + #[test] + fn test_sendfile_copies_data() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let in_fd = sys_open(&mut proc, &mut host, b"/test/in", O_RDONLY, 0).unwrap(); + let out_fd = sys_open(&mut proc, &mut host, b"/test/out", O_WRONLY, 0).unwrap(); + // TrackingHostIO reads 5 bytes ("hello") per call, count=10 → copies 10 bytes + let result = sys_sendfile(&mut proc, &mut host, out_fd, in_fd, 0, 10); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 10); + } + + // ===== statx tests ===== + + #[test] + fn test_statx_delegates_to_fstatat() { + let mut proc = Process::new(1); + let mut host = TrackingHostIO::new(); + let result = sys_statx(&mut proc, &mut host, -100, b"/test/file", 0, 0); + assert!(result.is_ok()); + assert_eq!(host.last_stat_path, b"/test/file"); + } + + // ---- virtual device tests ---- + + #[test] + fn test_stat_dev_null() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let st = sys_stat(&mut proc, &mut host, b"/dev/null").unwrap(); + assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); + assert_eq!(st.st_mode & 0o777, 0o666); + assert_eq!(st.st_ino, 1); + assert_eq!(st.st_dev, 5); + } + + #[test] + fn test_fstat_dev_zero() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_RDONLY, 0).unwrap(); + let st = sys_fstat(&mut proc, &mut host, fd).unwrap(); + assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); + assert_eq!(st.st_ino, 2); + } + + #[test] + fn test_lseek_dev_null() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); + let pos = sys_lseek(&mut proc, &mut host, fd, 100, 0).unwrap(); + assert_eq!(pos, 0); + } + + #[test] + fn test_access_dev_urandom() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_access(&mut proc, &mut host, b"/dev/urandom", 4).unwrap(); + } + + #[test] + fn test_close_dev_null() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); + sys_close(&mut proc, &mut host, fd).unwrap(); + assert!(proc.fd_table.get(fd).is_err()); + } + + #[test] + fn test_stat_dev_fd_path() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let st = sys_stat(&mut proc, &mut host, b"/dev/fd/0").unwrap(); + assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); + } + + #[test] + fn test_read_dev_null_eof() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDONLY, 0).unwrap(); + let mut buf = [0u8; 64]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn test_write_dev_null_discards() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_WRONLY, 0).unwrap(); + let n = sys_write(&mut proc, &mut host, fd, b"hello world").unwrap(); + assert_eq!(n, 11); + } + + #[test] + fn test_dev_console_aliases_dev_null() { + // /dev/console is an alias for /dev/null: write-discard, read-EOF. + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/console", O_RDWR, 0).unwrap(); + let n = sys_write(&mut proc, &mut host, fd, b"console msg").unwrap(); + assert_eq!(n, 11); + let mut buf = [0u8; 64]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 0); + } + + #[test] + fn test_read_dev_zero_fills() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_RDONLY, 0).unwrap(); + let mut buf = [0xFFu8; 32]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 32); + assert!(buf.iter().all(|&b| b == 0)); + } + + #[test] + fn test_read_dev_urandom_returns_bytes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/urandom", O_RDONLY, 0).unwrap(); + let mut buf = [0xFFu8; 32]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 32); + // MockHostIO.host_getrandom fills with (i & 0xFF) pattern + assert_eq!(buf[0], 0); + assert_eq!(buf[1], 1); + assert_eq!(buf[31], 31); + } + + #[test] + fn test_read_dev_full_fills_zeros() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/full", O_RDONLY, 0).unwrap(); + let mut buf = [0xFFu8; 16]; + let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); + assert_eq!(n, 16); + assert!(buf.iter().all(|&b| b == 0)); + } + + #[test] + fn test_write_dev_full_enospc() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/full", O_WRONLY, 0).unwrap(); + let result = sys_write(&mut proc, &mut host, fd, b"data"); + assert_eq!(result, Err(Errno::ENOSPC)); + } + + #[test] + fn test_write_dev_zero_discards() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_WRONLY, 0).unwrap(); + let n = sys_write(&mut proc, &mut host, fd, b"data").unwrap(); + assert_eq!(n, 4); + } + + #[test] + fn test_open_dev_null() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); + assert!(fd >= 3); + let ofd = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap(); + assert_eq!(ofd.file_type, FileType::CharDevice); + assert_eq!(ofd.host_handle, -1); + } + + #[test] + fn test_open_dev_zero() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_RDONLY, 0).unwrap(); + let ofd = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap(); + assert_eq!(ofd.host_handle, -2); + } + + #[test] + fn test_open_dev_urandom() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/urandom", O_RDONLY, 0).unwrap(); + let ofd = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap(); + assert_eq!(ofd.host_handle, -3); + } + + #[test] + fn test_open_dev_full() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/full", O_RDWR, 0).unwrap(); + let ofd = proc + .ofd_table + .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + .unwrap(); + assert_eq!(ofd.host_handle, -4); + } + + #[test] + fn test_open_dev_fd_dups_existing() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + // fd 1 = stdout, opening /dev/fd/1 should dup it + let fd = sys_open(&mut proc, &mut host, b"/dev/fd/1", O_WRONLY, 0).unwrap(); + assert!(fd >= 3); + let ofd_ref_1 = proc.fd_table.get(1).unwrap().ofd_ref.0; + let ofd_ref_new = proc.fd_table.get(fd).unwrap().ofd_ref.0; + assert_eq!(ofd_ref_1, ofd_ref_new); + } + + #[test] + fn test_open_dev_stdin_alias() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_open(&mut proc, &mut host, b"/dev/stdin", O_RDONLY, 0).unwrap(); + let ofd_ref_0 = proc.fd_table.get(0).unwrap().ofd_ref.0; + let ofd_ref_new = proc.fd_table.get(fd).unwrap().ofd_ref.0; + assert_eq!(ofd_ref_0, ofd_ref_new); + } + + #[test] + fn test_open_dev_fd_nonexistent() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let result = sys_open(&mut proc, &mut host, b"/dev/fd/999", O_RDONLY, 0); + assert_eq!(result, Err(Errno::EBADF)); + } + + #[test] + fn test_match_virtual_device() { + assert_eq!( + match_virtual_device(b"/dev/null"), + Some(VirtualDevice::Null) + ); + assert_eq!( + match_virtual_device(b"/dev/console"), + Some(VirtualDevice::Null) + ); + assert_eq!( + match_virtual_device(b"/dev/zero"), + Some(VirtualDevice::Zero) + ); + assert_eq!( + match_virtual_device(b"/dev/urandom"), + Some(VirtualDevice::Urandom) + ); + assert_eq!( + match_virtual_device(b"/dev/random"), + Some(VirtualDevice::Urandom) + ); + assert_eq!( + match_virtual_device(b"/dev/full"), + Some(VirtualDevice::Full) + ); + assert_eq!(match_virtual_device(b"/dev/tty"), None); + assert_eq!(match_virtual_device(b"/tmp/foo"), None); + } + + #[test] + fn test_match_dev_fd() { + assert_eq!(match_dev_fd(b"/dev/stdin"), Some(0)); + assert_eq!(match_dev_fd(b"/dev/stdout"), Some(1)); + assert_eq!(match_dev_fd(b"/dev/stderr"), Some(2)); + assert_eq!(match_dev_fd(b"/dev/fd/0"), Some(0)); + assert_eq!(match_dev_fd(b"/dev/fd/5"), Some(5)); + assert_eq!(match_dev_fd(b"/dev/fd/123"), Some(123)); + assert_eq!(match_dev_fd(b"/dev/fd/"), None); + assert_eq!(match_dev_fd(b"/dev/fd/abc"), None); + assert_eq!(match_dev_fd(b"/tmp/foo"), None); + } + + #[test] + fn test_virtual_device_roundtrip() { + for dev in [ + VirtualDevice::Null, + VirtualDevice::Zero, + VirtualDevice::Urandom, + VirtualDevice::Full, + VirtualDevice::Fb0, + VirtualDevice::Mice, + VirtualDevice::Dsp, + VirtualDevice::DriRenderD128, + VirtualDevice::DriCard0, + ] { + assert_eq!( + VirtualDevice::from_host_handle(dev.host_handle()), + Some(dev) + ); + } + assert_eq!(VirtualDevice::from_host_handle(0), None); + // First sentinel past the allocated range — must not roundtrip. + assert_eq!(VirtualDevice::from_host_handle(-10), None); + } + + // ===== Loopback socket tests ===== + + #[test] + fn test_bind_inet_ephemeral_port() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + // Bind with port=0 → ephemeral + let addr = [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + // getsockname should show the assigned ephemeral port + let mut buf = [0u8; 16]; + let n = sys_getsockname(&proc, fd, &mut buf).unwrap(); + assert_eq!(n, 16); + assert_eq!(buf[0], 2); // AF_INET + let port = u16::from_be_bytes([buf[2], buf[3]]); + assert!( + port >= 49152, + "ephemeral port should be >= 49152, got {}", + port + ); + } + + #[test] + fn test_getsockname_inet_explicit_port() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + // Bind to port 8080 + let mut addr = [0u8; 16]; + addr[0] = 2; + addr[2] = 0x1F; + addr[3] = 0x90; // 8080 big-endian + addr[4] = 127; + addr[5] = 0; + addr[6] = 0; + addr[7] = 1; + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + let mut buf = [0u8; 16]; + sys_getsockname(&proc, fd, &mut buf).unwrap(); + assert_eq!(buf[0], 2); // AF_INET + let port = u16::from_be_bytes([buf[2], buf[3]]); + assert_eq!(port, 8080); + assert_eq!(&buf[4..8], &[127, 0, 0, 1]); + } + + #[test] + fn test_tcp_loopback() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + // Server: socket → bind → listen + let server_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + addr[2] = 0x1F; + addr[3] = 0x90; // port 8080 + sys_bind(&mut proc, &mut host, server_fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + // Client: socket → connect to 127.0.0.1:8080 + let client_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut connect_addr = [0u8; 16]; + connect_addr[0] = 2; + connect_addr[2] = 0x1F; + connect_addr[3] = 0x90; // port 8080 + connect_addr[4] = 127; + connect_addr[5] = 0; + connect_addr[6] = 0; + connect_addr[7] = 1; + sys_connect(&mut proc, &mut host, client_fd, &connect_addr).unwrap(); + + // Server: accept + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + + // Client writes, server reads + let written = sys_write(&mut proc, &mut host, client_fd, b"hello TCP").unwrap(); + assert_eq!(written, 9); + + let mut buf = [0u8; 64]; + let n = sys_read(&mut proc, &mut host, accepted_fd, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"hello TCP"); + + // Server writes, client reads + let written = sys_write(&mut proc, &mut host, accepted_fd, b"reply").unwrap(); + assert_eq!(written, 5); + + let mut buf2 = [0u8; 64]; + let n2 = sys_read(&mut proc, &mut host, client_fd, &mut buf2).unwrap(); + assert_eq!(&buf2[..n2], b"reply"); + } + + #[test] + fn test_accept_does_not_inherit_listener_nonblock_status() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::fcntl_cmd::F_SETFL; + use wasm_posix_shared::socket::*; + + let server_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + sys_fcntl(&mut proc, server_fd, F_SETFL, O_NONBLOCK).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; + addr[2] = 0x23; + addr[3] = 0x8d; // port 9101 + sys_bind(&mut proc, &mut host, server_fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + let client_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut connect_addr = [0u8; 16]; + connect_addr[0] = 2; + connect_addr[2] = 0x23; + connect_addr[3] = 0x8d; + connect_addr[4] = 127; + connect_addr[7] = 1; + sys_connect(&mut proc, &mut host, client_fd, &connect_addr).unwrap(); + + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + let entry = proc.fd_table.get(accepted_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + assert_eq!( + ofd.status_flags & O_NONBLOCK, + 0, + "accept() should leave the accepted OFD blocking", + ); + } + + #[test] + fn test_tcp_loopback_close_drains_then_discards_post_fin_writes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + let server_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + addr[2] = 0x23; + addr[3] = 0x9b; // port 9115 + sys_bind(&mut proc, &mut host, server_fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + + let client_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut connect_addr = [0u8; 16]; + connect_addr[0] = 2; + connect_addr[2] = 0x23; + connect_addr[3] = 0x9b; + connect_addr[4] = 127; + connect_addr[7] = 1; + sys_connect(&mut proc, &mut host, client_fd, &connect_addr).unwrap(); + let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + + let client_sock_idx = { + let entry = proc.fd_table.get(client_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + let client_send_idx = proc + .sockets + .get(client_sock_idx) + .unwrap() + .send_buf_idx + .unwrap(); + let client_recv_idx = proc + .sockets + .get(client_sock_idx) + .unwrap() + .recv_buf_idx + .unwrap(); + + sys_write(&mut proc, &mut host, client_fd, b"request").unwrap(); + let mut buf = [0u8; 16]; + let n = sys_read(&mut proc, &mut host, accepted_fd, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"request"); + + sys_write(&mut proc, &mut host, accepted_fd, b"queued").unwrap(); + sys_close(&mut proc, &mut host, accepted_fd).unwrap(); + let queued = sys_read(&mut proc, &mut host, client_fd, &mut buf).unwrap(); + assert_eq!(&buf[..queued], b"queued"); + let eof = sys_read(&mut proc, &mut host, client_fd, &mut buf).unwrap(); + assert_eq!(eof, 0); + + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"post-fin-one").unwrap(), + 12 + ); + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"post-fin-two").unwrap(), + 12 + ); + + sys_close(&mut proc, &mut host, client_fd).unwrap(); + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + assert!(pipe_table.get(client_send_idx).is_none()); + assert!(pipe_table.get(client_recv_idx).is_none()); + } + + #[test] + fn test_udp_loopback() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; + + // Create and bind a UDP socket (receiver) + let recv_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; + // port=0 → ephemeral + sys_bind(&mut proc, &mut host, recv_fd, &addr).unwrap(); + + // Get the assigned port + let mut gsa_buf = [0u8; 16]; + sys_getsockname(&proc, recv_fd, &mut gsa_buf).unwrap(); + let port = u16::from_be_bytes([gsa_buf[2], gsa_buf[3]]); + + // Create and bind a sender UDP socket + let send_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut sender_addr = [0u8; 16]; + sender_addr[0] = 2; + sys_bind(&mut proc, &mut host, send_fd, &sender_addr).unwrap(); + + // Send to the receiver via loopback + let mut dest_addr = [0u8; 16]; + dest_addr[0] = 2; + let port_be = port.to_be_bytes(); + dest_addr[2] = port_be[0]; + dest_addr[3] = port_be[1]; + dest_addr[4] = 127; + dest_addr[5] = 0; + dest_addr[6] = 0; + dest_addr[7] = 1; + let n = sys_sendto(&mut proc, &mut host, send_fd, b"hello UDP", 0, &dest_addr).unwrap(); + assert_eq!(n, 9); + + // Receive + let mut buf = [0u8; 64]; + let mut from_addr = [0u8; 16]; + let (data_len, addr_len) = + sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from_addr).unwrap(); + assert_eq!(&buf[..data_len], b"hello UDP"); + assert_eq!(addr_len, 16); + assert_eq!(from_addr[0], 2); // AF_INET + } - // Create socket - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + #[test] + fn test_ipv4_limited_broadcast_requires_so_broadcast() { + let mut proc = Process::new(9060); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; - // Connect to a non-loopback address to test host delegation - let addr = [2, 0, 0, 80, 93, 184, 216, 34, 0, 0, 0, 0, 0, 0, 0, 0]; - sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut dest = [0u8; 16]; + dest[0] = AF_INET as u8; + dest[3] = 9; + dest[4..8].fill(255); - // Write should succeed (delegating to host_net_send) - let result = sys_write(&mut proc, &mut host, fd, b"hello world"); - assert!( - result.is_ok(), - "sys_write on connected AF_INET socket should succeed, got: {:?}", - result + assert_eq!( + sys_sendto(&mut proc, &mut host, fd, b"x", 0, &dest).unwrap_err(), + Errno::EACCES, + ); + sys_setsockopt(&mut proc, fd, SOL_SOCKET, SO_BROADCAST, 1).unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, fd, b"x", 0, &dest).unwrap_err(), + Errno::ENETUNREACH, + "enabling SO_BROADCAST must pass the permission gate to HostIO", + ); + sys_setsockopt(&mut proc, fd, SOL_SOCKET, SO_BROADCAST, 0).unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, fd, b"x", 0, &dest).unwrap_err(), + Errno::EACCES, ); - assert_eq!(result.unwrap(), 11); - // Read should succeed (delegating to host_net_recv, returns 0 = EOF from mock) - let mut buf = [0u8; 64]; - let result = sys_read(&mut proc, &mut host, fd, &mut buf); - assert!( - result.is_ok(), - "sys_read on connected AF_INET socket should succeed, got: {:?}", - result + let device_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + sys_setsockopt_bindtodevice(&mut proc, device_fd, b"lo\0").unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, device_fd, b"x", 0, &dest).unwrap_err(), + Errno::EACCES, + "broadcast permission must be checked before device routing", + ); + sys_setsockopt(&mut proc, device_fd, SOL_SOCKET, SO_BROADCAST, 1).unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, device_fd, b"x", 0, &dest).unwrap_err(), + Errno::ENETUNREACH, + "the selected device may reject the route after permission is granted", ); - assert_eq!(result.unwrap(), 0); - } - // ===== setitimer / getitimer tests ===== + let error_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut loopback = [0u8; 16]; + loopback[0] = AF_INET as u8; + loopback[3] = 9; + loopback[4..8].copy_from_slice(&[127, 0, 0, 1]); + sys_connect(&mut proc, &mut host, error_fd, &loopback).unwrap(); + assert_eq!( + sys_send(&mut proc, &mut host, error_fd, b"prime error", 0).unwrap(), + 11, + ); + assert_eq!( + sys_sendto(&mut proc, &mut host, error_fd, b"x", 0, &dest).unwrap_err(), + Errno::ECONNREFUSED, + "a pending connected-socket error must precede broadcast permission", + ); + assert_eq!( + sys_sendto(&mut proc, &mut host, error_fd, b"x", 0, &dest).unwrap_err(), + Errno::EACCES, + ); - #[test] - fn test_getitimer_initial_zero() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_getitimer(&mut proc, &mut host, 0).unwrap(); - assert_eq!(result, (0, 0, 0, 0)); + let shutdown_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, shutdown_fd, &loopback).unwrap(); + sys_shutdown(&mut proc, &mut host, shutdown_fd, SHUT_WR).unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, shutdown_fd, b"x", 0, &dest).unwrap_err(), + Errno::EPIPE, + "write shutdown must precede broadcast permission", + ); } #[test] - fn test_getitimer_virtual_always_zero() { - let mut proc = Process::new(1); + fn test_socket_buffer_requests_do_not_fabricate_applied_capacity() { + let mut proc = Process::new(9061); let mut host = MockHostIO::new(); - let result = sys_getitimer(&mut proc, &mut host, 1).unwrap(); - assert_eq!(result, (0, 0, 0, 0)); + use wasm_posix_shared::socket::*; + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let reported_default = DEFAULT_PIPE_CAPACITY as u32; + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_RCVBUF).unwrap(), + reported_default, + ); + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_SNDBUF).unwrap(), + reported_default, + ); + + // Requests remain advisory until the kernel actually resizes socket + // storage. Retain the existing reported default rather than returning + // an unenforced requested value. + sys_setsockopt( + &mut proc, + fd, + SOL_SOCKET, + SO_RCVBUF, + reported_default.saturating_mul(2), + ) + .unwrap(); + sys_setsockopt(&mut proc, fd, SOL_SOCKET, SO_SNDBUF, 4096).unwrap(); + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_RCVBUF).unwrap(), + reported_default, + ); + assert_eq!( + sys_getsockopt(&mut proc, fd, SOL_SOCKET, SO_SNDBUF).unwrap(), + reported_default, + ); } #[test] - fn test_getitimer_invalid_which() { - let mut proc = Process::new(1); + fn test_ipv4_datagram_msg_trunc_reports_full_length() { + let mut proc = Process::new(9062); let mut host = MockHostIO::new(); - let result = sys_getitimer(&mut proc, &mut host, 3); - assert_eq!(result, Err(Errno::EINVAL)); + use wasm_posix_shared::socket::*; + + let recv_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut recv_addr = [0u8; 16]; + recv_addr[0] = AF_INET as u8; + sys_bind(&mut proc, &mut host, recv_fd, &recv_addr).unwrap(); + sys_getsockname(&proc, recv_fd, &mut recv_addr).unwrap(); + recv_addr[4..8].copy_from_slice(&[127, 0, 0, 1]); + + let send_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + sys_sendto( + &mut proc, + &mut host, + send_fd, + b"0123456789", + 0, + &recv_addr, + ) + .unwrap(); + + let mut storage = [0xa5u8; 6]; + let mut from = [0u8; 16]; + let (peeked, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut storage[..4], + MSG_PEEK | MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(peeked, 10); + assert_eq!(&storage[..4], b"0123"); + assert_eq!(&storage[4..], &[0xa5, 0xa5]); + let (received, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut storage[..4], + MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(received, 10); + assert_eq!(&storage[..4], b"0123"); + assert_eq!(&storage[4..], &[0xa5, 0xa5]); + + sys_sendto( + &mut proc, + &mut host, + send_fd, + b"abcdefghij", + 0, + &recv_addr, + ) + .unwrap(); + let (copied, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut storage[..4], + 0, + &mut from, + ) + .unwrap(); + assert_eq!(copied, 4); + assert_eq!(&storage[..4], b"abcd"); + assert_eq!(&storage[4..], &[0xa5, 0xa5]); + + sys_sendto( + &mut proc, + &mut host, + send_fd, + b"zero-buffer", + 0, + &recv_addr, + ) + .unwrap(); + let mut empty = []; + let (zero_buffer_len, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut empty, + MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(zero_buffer_len, 11); + + let mut sender_addr = [0u8; 16]; + sys_getsockname(&proc, send_fd, &mut sender_addr).unwrap(); + sender_addr[4..8].copy_from_slice(&[127, 0, 0, 1]); + sys_connect(&mut proc, &mut host, recv_fd, &sender_addr).unwrap(); + sys_sendto( + &mut proc, + &mut host, + send_fd, + b"connected-recv", + 0, + &recv_addr, + ) + .unwrap(); + let connected_len = sys_recv( + &mut proc, + &mut host, + recv_fd, + &mut storage[..4], + MSG_TRUNC, + ) + .unwrap(); + assert_eq!(connected_len, 14); + assert_eq!(&storage[..4], b"conn"); } #[test] - fn test_setitimer_cancel() { - let mut proc = Process::new(1); + fn test_ipv6_datagram_msg_trunc_reports_full_length() { + let mut proc = Process::new(9063); let mut host = MockHostIO::new(); - // Set an alarm first - proc.alarm_deadline_ns = 1_000_000_000; - proc.alarm_interval_ns = 500_000_000; - // Cancel it - let old = sys_setitimer(&mut proc, &mut host, 0, 0, 0, 0, 0).unwrap(); - // Old should have had interval 0.5s - assert_eq!(old.0, 0); // interval_sec - assert_eq!(old.1, 500000); // interval_usec - // Now timer should be cleared - assert_eq!(proc.alarm_deadline_ns, 0); - assert_eq!(proc.alarm_interval_ns, 0); + use wasm_posix_shared::socket::*; + + let recv_fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + let mut recv_addr = [0u8; 28]; + recv_addr[0] = AF_INET6 as u8; + recv_addr[23] = 1; + sys_bind(&mut proc, &mut host, recv_fd, &recv_addr).unwrap(); + sys_getsockname(&proc, recv_fd, &mut recv_addr).unwrap(); + + let send_fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + sys_sendto( + &mut proc, + &mut host, + send_fd, + b"ipv6-truncated", + 0, + &recv_addr, + ) + .unwrap(); + let mut buf = [0u8; 4]; + let mut from = [0u8; 28]; + let (received, from_len) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut buf, + MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(received, 14); + assert_eq!(from_len, 28); + assert_eq!(&buf, b"ipv6"); } #[test] - fn test_setitimer_sets_alarm() { - let mut proc = Process::new(1); + fn test_unix_datagram_msg_trunc_preserves_peek_and_reports_full_length() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9064); let mut host = MockHostIO::new(); - // MockHostIO returns time (1234567890, 123456789) - let now_ns = 1234567890u64 * 1_000_000_000 + 123456789u64; - // Set a 2.5 second timer with 1 second interval - let result = sys_setitimer(&mut proc, &mut host, 0, 1, 0, 2, 500000).unwrap(); - // Old was zero - assert_eq!(result, (0, 0, 0, 0)); - // Interval should be stored - assert_eq!(proc.alarm_interval_ns, 1_000_000_000); - // Deadline should be now_ns + 2.5s - assert_eq!(proc.alarm_deadline_ns, now_ns + 2_500_000_000); + use wasm_posix_shared::socket::MSG_TRUNC; + + let recv_path = b"/tmp/udg-msg-trunc-recv.sock"; + let send_path = b"/tmp/udg-msg-trunc-send.sock"; + let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); + let (send_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, send_path); + sys_connect(&mut proc, &mut host, send_fd, &recv_addr).unwrap(); + sys_send( + &mut proc, + &mut host, + send_fd, + b"reliable-unix", + 0, + ) + .unwrap(); + + let mut buf = [0u8; 4]; + let mut from = [0u8; 16]; + let (peeked, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut buf, + wasm_posix_shared::socket::MSG_PEEK | MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(peeked, 13); + assert_eq!(&buf, b"reli"); + let (received, from_len) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut buf, + MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(received, 13); + assert_eq!(from_len, 2); + assert_eq!(&buf, b"reli"); + + for fd in [send_fd, recv_fd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } + for path in [send_path.as_slice(), recv_path.as_slice()] { + sys_unlink(&mut proc, &mut host, path).unwrap(); + } } #[test] - fn test_setitimer_virtual_noop() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let result = sys_setitimer(&mut proc, &mut host, 1, 1, 0, 1, 0).unwrap(); - assert_eq!(result, (0, 0, 0, 0)); - // ITIMER_VIRTUAL doesn't affect alarm_deadline_ns - assert_eq!(proc.alarm_deadline_ns, 0); - } + fn test_unix_datagram_msg_trunc_peek_preserves_full_queue_backpressure() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9065); + let mut host = MockHostIO::new(); + use wasm_posix_shared::poll::POLLOUT; + use wasm_posix_shared::socket::{MSG_PEEK, MSG_TRUNC}; + + let recv_path = b"/tmp/udg-msg-trunc-full-recv.sock"; + let send_path = b"/tmp/udg-msg-trunc-full-send.sock"; + let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); + let (send_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, send_path); + sys_connect(&mut proc, &mut host, send_fd, &recv_addr).unwrap(); + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT { + sys_send( + &mut proc, + &mut host, + send_fd, + &(sequence as u32).to_le_bytes(), + 0, + ) + .unwrap(); + } - // ===== sigtimedwait tests ===== + let mut pollfd = WasmPollFd { + fd: send_fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 0, + ); + let mut byte = [0u8; 1]; + let mut from = [0u8; 16]; + let (peeked, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut byte, + MSG_PEEK | MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(peeked, 4); + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 0, + "MSG_PEEK must neither consume capacity nor wake the sender", + ); - #[test] - fn test_sigtimedwait_pending_signal() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // Raise SIGUSR1 (signal 10) - proc.signals.pending |= crate::signal::sig_bit(10); - // Wait for SIGUSR1 - let mask = crate::signal::sig_bit(10); - let (sig, _val, _code) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); - assert_eq!(sig, 10); - // Signal should be dequeued - assert_eq!(proc.signals.pending & crate::signal::sig_bit(10), 0); - } + let (received, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut byte, + MSG_TRUNC, + &mut from, + ) + .unwrap(); + assert_eq!(received, 4); + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(pollfd.revents & POLLOUT, 0); - #[test] - fn test_sigtimedwait_no_pending_timeout_zero() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let mask = crate::signal::sig_bit(10); - let result = sys_sigtimedwait(&mut proc, &mut host, mask, 0); - assert_eq!(result, Err(Errno::EAGAIN)); + for fd in [send_fd, recv_fd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } + for path in [send_path.as_slice(), recv_path.as_slice()] { + sys_unlink(&mut proc, &mut host, path).unwrap(); + } } #[test] - fn test_sigtimedwait_dequeues_lowest() { + fn test_ipv4_multicast_loopback_membership_and_filters() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - // Raise both SIGUSR1 (10) and SIGUSR2 (12) - proc.signals.pending |= crate::signal::sig_bit(10) | crate::signal::sig_bit(12); - let mask = crate::signal::sig_bit(10) | crate::signal::sig_bit(12); - let (sig, _val, _code) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); - assert_eq!(sig, 10); // lowest first - // Only SIGUSR1 should be dequeued - assert_eq!(proc.signals.pending & crate::signal::sig_bit(10), 0); - assert_ne!(proc.signals.pending & crate::signal::sig_bit(12), 0); - } + use wasm_posix_shared::socket::*; - #[test] - fn test_sigtimedwait_rt_queued_multiple() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - // Queue SIGRTMIN (32) three times - proc.signals.raise(32); - proc.signals.raise(32); - proc.signals.raise(32); - let mask = crate::signal::sig_bit(32); - // First dequeue should return signal 32 and leave 2 queued - let (s1, _, _) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); - assert_eq!(s1, 32); - assert!( - proc.signals.is_pending(32), - "should still be pending with 2 queued" + let recv_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut bind_any = [0u8; 16]; + bind_any[0] = 2; + sys_bind(&mut proc, &mut host, recv_fd, &bind_any).unwrap(); + let mut gsa_buf = [0u8; 16]; + sys_getsockname(&proc, recv_fd, &mut gsa_buf).unwrap(); + let port = u16::from_be_bytes([gsa_buf[2], gsa_buf[3]]); + + let send_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut bind_loopback = [0u8; 16]; + bind_loopback[0] = 2; + bind_loopback[4] = 127; + bind_loopback[7] = 1; + sys_bind(&mut proc, &mut host, send_fd, &bind_loopback).unwrap(); + + let group = [224, 0, 0, 23]; + let lo = [127, 0, 0, 1]; + sys_setsockopt_ipv4_multicast( + &mut proc, + recv_fd, + MCAST_JOIN_GROUP, + group, + lo, + None, + ) + .unwrap(); + + let mut dest = [0u8; 16]; + dest[0] = 2; + dest[2..4].copy_from_slice(&port.to_be_bytes()); + dest[4..8].copy_from_slice(&group); + assert_eq!( + sys_sendto(&mut proc, &mut host, send_fd, b"initial", 0, &dest).unwrap(), + 7 ); - // Second - let (s2, _, _) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); - assert_eq!(s2, 32); + + let mut buf = [0u8; 32]; + let mut from = [0u8; 16]; + let (n, _) = + sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + assert_eq!(&buf[..n], b"initial"); + assert_eq!(&from[4..8], &lo); + + sys_setsockopt_ipv4_multicast( + &mut proc, + recv_fd, + MCAST_BLOCK_SOURCE, + group, + lo, + Some(lo), + ) + .unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, send_fd, b"blocked", 0, &dest).unwrap(), + 7 + ); + let recv_idx = { + let entry = proc.fd_table.get(recv_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; assert!( - proc.signals.is_pending(32), - "should still be pending with 1 queued" + proc.sockets + .get(recv_idx) + .unwrap() + .dgram_queue + .is_empty(), + "blocked multicast source should not enqueue a datagram" ); - // Third - let (s3, _, _) = sys_sigtimedwait(&mut proc, &mut host, mask, 0).unwrap(); - assert_eq!(s3, 32); - assert!(!proc.signals.is_pending(32), "should no longer be pending"); - // Fourth should fail - let r4 = sys_sigtimedwait(&mut proc, &mut host, mask, 0); - assert_eq!(r4, Err(Errno::EAGAIN)); - } - // ===== preadv / pwritev tests ===== + sys_setsockopt_ipv4_multicast( + &mut proc, + recv_fd, + MCAST_UNBLOCK_SOURCE, + group, + lo, + Some(lo), + ) + .unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, send_fd, b"unblocked", 0, &dest).unwrap(), + 9 + ); + let (n, _) = + sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + assert_eq!(&buf[..n], b"unblocked"); - #[test] - fn test_preadv_basic() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/test/file", O_RDONLY, 0).unwrap(); - let mut buf1 = [0u8; 4]; - let mut buf2 = [0u8; 4]; - let mut iovecs: [&mut [u8]; 2] = [&mut buf1, &mut buf2]; - // TrackingHostIO reads return 0 (EOF), so total should be 0 - let result = sys_preadv(&mut proc, &mut host, fd, &mut iovecs, 0); - assert!(result.is_ok()); - } + sys_setsockopt(&mut proc, send_fd, IPPROTO_IP, IP_MULTICAST_LOOP, 0).unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, send_fd, b"no-loop", 0, &dest).unwrap(), + 7 + ); + assert!( + proc.sockets + .get(recv_idx) + .unwrap() + .dgram_queue + .is_empty(), + "IP_MULTICAST_LOOP=0 must suppress local delivery" + ); + sys_setsockopt(&mut proc, send_fd, IPPROTO_IP, IP_MULTICAST_LOOP, 1).unwrap(); - #[test] - fn test_preadv_rejects_pipe() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let (read_fd, _write_fd) = sys_pipe(&mut proc).unwrap(); - let mut buf = [0u8; 4]; - let mut iovecs: [&mut [u8]; 1] = [&mut buf]; - let result = sys_preadv(&mut proc, &mut host, read_fd, &mut iovecs, 0); - assert_eq!(result, Err(Errno::ESPIPE)); + sys_setsockopt_ipv4_multicast( + &mut proc, + recv_fd, + MCAST_LEAVE_GROUP, + group, + lo, + None, + ) + .unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, send_fd, b"ignored", 0, &dest).unwrap(), + 7 + ); + assert!( + proc.sockets + .get(recv_idx) + .unwrap() + .dgram_queue + .is_empty(), + "leaving a multicast group should stop group delivery" + ); } #[test] - fn test_pwritev_rejects_pipe() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let (_read_fd, write_fd) = sys_pipe(&mut proc).unwrap(); - let iovecs: [&[u8]; 1] = [b"hello"]; - let result = sys_pwritev(&mut proc, &mut host, write_fd, &iovecs, 0); - assert_eq!(result, Err(Errno::ESPIPE)); - } - - // ===== sendfile tests ===== + fn test_multicast_group_request_offsets_cover_wasm32_and_wasm64() { + assert_eq!( + multicast_group_request_offsets(&[0u8; 132], false).unwrap(), + (4, None) + ); + assert_eq!( + multicast_group_request_offsets(&[0u8; 136], false).unwrap(), + (8, None) + ); + assert_eq!( + multicast_group_request_offsets(&[0u8; 260], true).unwrap(), + (4, Some(132)) + ); + assert_eq!( + multicast_group_request_offsets(&[0u8; 264], true).unwrap(), + (8, Some(136)) + ); + assert_eq!( + multicast_group_request_offsets(&[0u8; 131], false).unwrap_err(), + Errno::EINVAL + ); - #[test] - fn test_sendfile_copies_data() { - let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let in_fd = sys_open(&mut proc, &mut host, b"/test/in", O_RDONLY, 0).unwrap(); - let out_fd = sys_open(&mut proc, &mut host, b"/test/out", O_WRONLY, 0).unwrap(); - // TrackingHostIO reads 5 bytes ("hello") per call, count=10 → copies 10 bytes - let result = sys_sendfile(&mut proc, &mut host, out_fd, in_fd, 0, 10); - assert!(result.is_ok()); - assert_eq!(result.unwrap(), 10); + let mut oversized32 = [0u8; 140]; + oversized32[4] = wasm_posix_shared::socket::AF_INET as u8; + assert_eq!( + multicast_group_request_offsets(&oversized32, false).unwrap(), + (4, None) + ); + let mut ambiguous = oversized32; + ambiguous[8] = wasm_posix_shared::socket::AF_INET as u8; + assert_eq!( + multicast_group_request_offsets(&ambiguous, false).unwrap_err(), + Errno::EINVAL + ); } - // ===== statx tests ===== - #[test] - fn test_statx_delegates_to_fstatat() { + fn test_ipv4_multicast_source_membership_and_interface_match() { let mut proc = Process::new(1); - let mut host = TrackingHostIO::new(); - let result = sys_statx(&mut proc, &mut host, -100, b"/test/file", 0, 0); - assert!(result.is_ok()); - assert_eq!(host.last_stat_path, b"/test/file"); - } + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; - // ---- virtual device tests ---- + let recv_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut bind_any = [0u8; 16]; + bind_any[0] = 2; + bind_any[2] = 0x45; + bind_any[3] = 0x67; + sys_bind(&mut proc, &mut host, recv_fd, &bind_any).unwrap(); + + let loop_sender = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut bind_loopback = [0u8; 16]; + bind_loopback[0] = 2; + bind_loopback[4] = 127; + bind_loopback[7] = 1; + sys_bind(&mut proc, &mut host, loop_sender, &bind_loopback).unwrap(); + + let default_sender = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let group = [224, 0, 0, 23]; + let lo = [127, 0, 0, 1]; + sys_setsockopt_ipv4_multicast( + &mut proc, + recv_fd, + MCAST_JOIN_SOURCE_GROUP, + group, + lo, + Some(lo), + ) + .unwrap(); - #[test] - fn test_stat_dev_null() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let st = sys_stat(&mut proc, &mut host, b"/dev/null").unwrap(); - assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); - assert_eq!(st.st_mode & 0o777, 0o666); - assert_eq!(st.st_ino, 1); - assert_eq!(st.st_dev, 5); - } + let mut dest = [0u8; 16]; + dest[0] = 2; + dest[2] = 0x45; + dest[3] = 0x67; + dest[4..8].copy_from_slice(&group); - #[test] - fn test_fstat_dev_zero() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_RDONLY, 0).unwrap(); - let st = sys_fstat(&mut proc, &mut host, fd).unwrap(); - assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); - assert_eq!(st.st_ino, 2); - } + // The receiver joined the group on loopback only. An unbound sender + // uses the default interface and must not satisfy that membership, + // but the UDP multicast send itself still succeeds. + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + default_sender, + b"default-iface", + 0, + &dest + ) + .unwrap(), + 13 + ); + let recv_idx = { + let entry = proc.fd_table.get(recv_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + assert!( + proc.sockets + .get(recv_idx) + .unwrap() + .dgram_queue + .is_empty() + ); - #[test] - fn test_lseek_dev_null() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); - let pos = sys_lseek(&mut proc, &mut host, fd, 100, 0).unwrap(); - assert_eq!(pos, 0); - } + sys_setsockopt( + &mut proc, + default_sender, + IPPROTO_IP, + IP_MULTICAST_IF, + u32::from_le_bytes(lo), + ) + .unwrap(); + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + default_sender, + b"selected-loopback", + 0, + &dest, + ) + .unwrap(), + 17 + ); + let mut selected_buf = [0u8; 32]; + let mut selected_from = [0u8; 16]; + let (selected_len, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut selected_buf, + 0, + &mut selected_from, + ) + .unwrap(); + assert_eq!(&selected_buf[..selected_len], b"selected-loopback"); + assert_eq!(&selected_from[4..8], &lo); - #[test] - fn test_access_dev_urandom() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - sys_access(&mut proc, &mut host, b"/dev/urandom", 4).unwrap(); - } + sys_setsockopt( + &mut proc, + default_sender, + IPPROTO_IP, + IP_MULTICAST_IF, + 0, + ) + .unwrap(); + sys_setsockopt_bindtodevice(&mut proc, default_sender, b"lo\0").unwrap(); + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + default_sender, + b"bound-loopback", + 0, + &dest, + ) + .unwrap(), + 14 + ); + let (bound_len, _) = sys_recvfrom( + &mut proc, + &mut host, + recv_fd, + &mut selected_buf, + 0, + &mut selected_from, + ) + .unwrap(); + assert_eq!(&selected_buf[..bound_len], b"bound-loopback"); + assert_eq!(&selected_from[4..8], &lo); - #[test] - fn test_close_dev_null() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); - sys_close(&mut proc, &mut host, fd).unwrap(); - assert!(proc.fd_table.get(fd).is_err()); + assert_eq!( + sys_sendto(&mut proc, &mut host, loop_sender, b"source-match", 0, &dest).unwrap(), + 12 + ); + let mut buf = [0u8; 32]; + let mut from = [0u8; 16]; + let (n, _) = + sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + assert_eq!(&buf[..n], b"source-match"); + + sys_setsockopt_ipv4_multicast( + &mut proc, + recv_fd, + MCAST_LEAVE_SOURCE_GROUP, + group, + lo, + Some(lo), + ) + .unwrap(); + assert_eq!( + sys_sendto(&mut proc, &mut host, loop_sender, b"left-source", 0, &dest).unwrap(), + 11 + ); + assert!( + proc.sockets + .get(recv_idx) + .unwrap() + .dgram_queue + .is_empty() + ); } #[test] - fn test_stat_dev_fd_path() { + fn test_udp_connect_to_inaddr_any_routes_to_loopback() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let st = sys_stat(&mut proc, &mut host, b"/dev/fd/0").unwrap(); - assert_eq!(st.st_mode & 0xF000, wasm_posix_shared::mode::S_IFCHR); - } + use wasm_posix_shared::socket::*; - #[test] - fn test_read_dev_null_eof() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDONLY, 0).unwrap(); - let mut buf = [0u8; 64]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 0); + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut addr = [0u8; 16]; + addr[0] = 2; // AF_INET + addr[3] = 80; // port 80, network byte order + + sys_connect(&mut proc, &mut host, fd, &addr).unwrap(); + + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let sock = proc.sockets.get(sock_idx).unwrap(); + assert_eq!(sock.peer_addr, [127, 0, 0, 1]); + assert_eq!(sock.bind_addr, [127, 0, 0, 1]); } #[test] - fn test_write_dev_null_discards() { - let mut proc = Process::new(1); + fn test_datagram_shutdown_send_obeys_msg_nosignal() { + let mut proc = Process::new(9034); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_WRONLY, 0).unwrap(); - let n = sys_write(&mut proc, &mut host, fd, b"hello world").unwrap(); - assert_eq!(n, 11); + use wasm_posix_shared::signal::SIGPIPE; + use wasm_posix_shared::socket::*; + + let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut peer = [0u8; 16]; + peer[0] = AF_INET as u8; + peer[2..4].copy_from_slice(&23103u16.to_be_bytes()); + peer[4..8].copy_from_slice(&[127, 0, 0, 1]); + sys_connect(&mut proc, &mut host, fd, &peer).unwrap(); + sys_shutdown(&mut proc, &mut host, fd, SHUT_WR).unwrap(); + + assert_eq!( + sys_send(&mut proc, &mut host, fd, b"signal", 0).unwrap_err(), + Errno::EPIPE, + ); + assert!(proc.signals.is_pending(SIGPIPE)); + proc.signals.clear(SIGPIPE); + assert_eq!( + sys_send(&mut proc, &mut host, fd, b"quiet", MSG_NOSIGNAL).unwrap_err(), + Errno::EPIPE, + ); + assert!(!proc.signals.is_pending(SIGPIPE)); } #[test] - fn test_dev_console_aliases_dev_null() { - // /dev/console is an alias for /dev/null: write-discard, read-EOF. + fn test_inet6_udp_loopback_datagram_delivery() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/console", O_RDWR, 0).unwrap(); - let n = sys_write(&mut proc, &mut host, fd, b"console msg").unwrap(); - assert_eq!(n, 11); - let mut buf = [0u8; 64]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 0); + use wasm_posix_shared::socket::*; + + let server_fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + let mut server_addr = [0u8; 28]; + server_addr[0] = 10; // AF_INET6 + server_addr[2] = 0x56; + server_addr[3] = 0xce; // port 22222 + server_addr[23] = 1; // ::1 + sys_bind(&mut proc, &mut host, server_fd, &server_addr).unwrap(); + + let client_fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, &server_addr).unwrap(); + assert_eq!(sys_write(&mut proc, &mut host, client_fd, b"udp6").unwrap(), 4); + + let mut buf = [0u8; 8]; + let mut from = [0u8; 28]; + let (n, from_len) = + sys_recvfrom(&mut proc, &mut host, server_fd, &mut buf, 0, &mut from).unwrap(); + assert_eq!(&buf[..n], b"udp6"); + assert_eq!(from_len, 28); + assert_eq!(&from[8..24], &[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]); } #[test] - fn test_read_dev_zero_fills() { - let mut proc = Process::new(1); + fn test_inet6_udp_bind_is_machine_scoped_and_truthfully_v6only() { + let mut first = Process::new(9037); + let mut second = Process::new(9038); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_RDONLY, 0).unwrap(); - let mut buf = [0xFFu8; 32]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 32); - assert!(buf.iter().all(|&b| b == 0)); + use wasm_posix_shared::socket::*; + + let fd1 = sys_socket(&mut first, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_getsockopt(&mut first, fd1, IPPROTO_IPV6, IPV6_V6ONLY).unwrap(), + 1, + ); + assert_eq!( + sys_setsockopt(&mut first, fd1, IPPROTO_IPV6, IPV6_V6ONLY, 0).unwrap_err(), + Errno::EOPNOTSUPP, + ); + let mut addr = [0u8; 28]; + addr[0] = AF_INET6 as u8; + addr[2..4].copy_from_slice(&23104u16.to_be_bytes()); + addr[23] = 1; + sys_bind(&mut first, &mut host, fd1, &addr).unwrap(); + + let fd2 = sys_socket(&mut second, &mut host, AF_INET6, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_bind(&mut second, &mut host, fd2, &addr).unwrap_err(), + Errno::EADDRINUSE, + ); + sys_close(&mut first, &mut host, fd1).unwrap(); + sys_bind(&mut second, &mut host, fd2, &addr).unwrap(); + sys_close(&mut second, &mut host, fd2).unwrap(); } #[test] - fn test_read_dev_urandom_returns_bytes() { + fn test_unix_dgram_loopback_datagram_delivery() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/urandom", O_RDONLY, 0).unwrap(); - let mut buf = [0xFFu8; 32]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 32); - // MockHostIO.host_getrandom fills with (i & 0xFF) pattern - assert_eq!(buf[0], 0); - assert_eq!(buf[1], 1); - assert_eq!(buf[31], 31); + use wasm_posix_shared::socket::*; + + proc.pid = 9025; + let server_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut addr = [0u8; 64]; + addr[0] = 1; // AF_UNIX + let path = b"/tmp/udg-loop.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + let addrlen = 2 + path.len() + 1; + sys_bind(&mut proc, &mut host, server_fd, &addr[..addrlen]).unwrap(); + + let client_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, &addr[..addrlen]).unwrap(); + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"unix-dgram").unwrap(), + 10, + ); + + let mut buf = [0u8; 16]; + let n = sys_read(&mut proc, &mut host, server_fd, &mut buf).unwrap(); + assert_eq!(&buf[..n], b"unix-dgram"); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9025); } #[test] - fn test_read_dev_full_fills_zeros() { - let mut proc = Process::new(1); + fn test_unix_dgram_full_queue_backpressures_without_reordering() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9052); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/full", O_RDONLY, 0).unwrap(); - let mut buf = [0xFFu8; 16]; - let n = sys_read(&mut proc, &mut host, fd, &mut buf).unwrap(); - assert_eq!(n, 16); - assert!(buf.iter().all(|&b| b == 0)); + use wasm_posix_shared::socket::*; + + let recv_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let recv_path = b"/tmp/udg-overflow-recv.sock"; + let mut recv_addr = [0u8; 64]; + recv_addr[0] = AF_UNIX as u8; + recv_addr[2..2 + recv_path.len()].copy_from_slice(recv_path); + let recv_addr = &recv_addr[..2 + recv_path.len() + 1]; + sys_bind(&mut proc, &mut host, recv_fd, recv_addr).unwrap(); + + let send_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let send_path = b"/tmp/udg-overflow-send.sock"; + let mut send_addr = [0u8; 64]; + send_addr[0] = AF_UNIX as u8; + send_addr[2..2 + send_path.len()].copy_from_slice(send_path); + let send_addr = &send_addr[..2 + send_path.len() + 1]; + sys_bind(&mut proc, &mut host, send_fd, send_addr).unwrap(); + sys_connect(&mut proc, &mut host, send_fd, recv_addr).unwrap(); + + // A connected receiver admits only its chosen peer. Purge datagrams + // from other senders that arrived before connect, and reject later + // attempts so invisible messages cannot fill the reliable queue. + let attacker_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + attacker_fd, + b"pre-connect attacker", + 0, + recv_addr, + ) + .unwrap(), + 20, + ); + let recv_idx = { + let entry = proc.fd_table.get(recv_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + assert_eq!( + sys_write( + &mut proc, + &mut host, + send_fd, + b"peer-before-connect", + ) + .unwrap(), + 19, + ); + assert_eq!(proc.sockets.get(recv_idx).unwrap().dgram_queue.len(), 2); + sys_connect(&mut proc, &mut host, recv_fd, send_addr).unwrap(); + assert_eq!(proc.sockets.get(recv_idx).unwrap().dgram_queue.len(), 1); + let mut peer_payload = [0u8; 32]; + let peer_len = sys_read(&mut proc, &mut host, recv_fd, &mut peer_payload).unwrap(); + assert_eq!(&peer_payload[..peer_len], b"peer-before-connect"); + assert!(proc.sockets.get(recv_idx).unwrap().dgram_queue.is_empty()); + assert_eq!( + sys_connect(&mut proc, &mut host, attacker_fd, recv_addr).unwrap_err(), + Errno::EPERM, + ); + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + attacker_fd, + b"post-connect attacker", + 0, + recv_addr, + ) + .unwrap_err(), + Errno::EPERM, + ); + + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT { + assert_eq!( + sys_write( + &mut proc, + &mut host, + send_fd, + &(sequence as u32).to_le_bytes(), + ) + .unwrap(), + 4, + ); + } + assert_eq!( + unix_queue_datagram(proc.sockets.get_mut(recv_idx).unwrap(), || { + panic!("a full Unix datagram queue must not construct a blocked payload") + }), + Err(Errno::EAGAIN), + ); + assert_eq!( + sys_write( + &mut proc, + &mut host, + send_fd, + &(UDP_DATAGRAM_QUEUE_LIMIT as u32).to_le_bytes(), + ) + .unwrap_err(), + Errno::EAGAIN, + ); + + use wasm_posix_shared::poll::POLLOUT; + let mut pollfd = WasmPollFd { + fd: send_fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 0, + ); + assert_eq!(pollfd.revents, 0); + + let mut buf = [0u8; 4]; + assert_eq!(sys_read(&mut proc, &mut host, recv_fd, &mut buf).unwrap(), 4); + assert_eq!(u32::from_le_bytes(buf), 0); + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(pollfd.revents & POLLOUT, 0); + assert_eq!( + sys_write( + &mut proc, + &mut host, + send_fd, + &(UDP_DATAGRAM_QUEUE_LIMIT as u32).to_le_bytes(), + ) + .unwrap(), + 4, + ); + + for expected in 1..=UDP_DATAGRAM_QUEUE_LIMIT { + assert_eq!(sys_read(&mut proc, &mut host, recv_fd, &mut buf).unwrap(), 4); + assert_eq!(u32::from_le_bytes(buf), expected as u32); + } + + sys_close(&mut proc, &mut host, attacker_fd).unwrap(); + sys_close(&mut proc, &mut host, send_fd).unwrap(); + sys_close(&mut proc, &mut host, recv_fd).unwrap(); + sys_unlink(&mut proc, &mut host, send_path).unwrap(); + sys_unlink(&mut proc, &mut host, recv_path).unwrap(); } #[test] - fn test_write_dev_full_enospc() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/full", O_WRONLY, 0).unwrap(); - let result = sys_write(&mut proc, &mut host, fd, b"data"); - assert_eq!(result, Err(Errno::ENOSPC)); + fn test_unix_dgram_connect_releases_rejected_full_queue_sender() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9053); + let mut host = MockHostIO::new(); + use wasm_posix_shared::poll::POLLOUT; + + let recv_path = b"/tmp/udg-connect-state-recv.sock"; + let first_path = b"/tmp/udg-connect-state-first.sock"; + let selected_path = b"/tmp/udg-connect-state-selected.sock"; + let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); + let (first_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, first_path); + let (selected_fd, selected_addr) = + bind_test_unix_dgram(&mut proc, &mut host, selected_path); + sys_connect(&mut proc, &mut host, first_fd, &recv_addr).unwrap(); + sys_connect(&mut proc, &mut host, selected_fd, &recv_addr).unwrap(); + + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT { + sys_send( + &mut proc, + &mut host, + selected_fd, + &(sequence as u32).to_le_bytes(), + 0, + ) + .unwrap(); + } + + let mut first_poll = WasmPollFd { + fd: first_fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut first_poll), + 0, + ) + .unwrap(), + 0, + ); + + // The receiver selects the other sender without freeing capacity. + // The first sender must nevertheless become ready so its next send + // can report the now-immediate EPERM instead of remaining parked. + sys_connect(&mut proc, &mut host, recv_fd, &selected_addr).unwrap(); + assert_eq!( + proc.sockets + .get(test_socket_idx(&proc, recv_fd)) + .unwrap() + .dgram_queue + .len(), + UDP_DATAGRAM_QUEUE_LIMIT, + ); + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut first_poll), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(first_poll.revents & POLLOUT, 0); + assert_eq!( + sys_send(&mut proc, &mut host, first_fd, b"rejected", 0).unwrap_err(), + Errno::EPERM, + ); + + let mut selected_poll = WasmPollFd { + fd: selected_fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut selected_poll), + 0, + ) + .unwrap(), + 0, + ); + + for fd in [first_fd, selected_fd, recv_fd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } + for path in [first_path.as_slice(), selected_path.as_slice(), recv_path.as_slice()] { + sys_unlink(&mut proc, &mut host, path).unwrap(); + } } #[test] - fn test_write_dev_zero_discards() { - let mut proc = Process::new(1); + fn test_unix_dgram_read_shutdown_releases_full_sender_to_epipe() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9054); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_WRONLY, 0).unwrap(); - let n = sys_write(&mut proc, &mut host, fd, b"data").unwrap(); - assert_eq!(n, 4); - } + use wasm_posix_shared::poll::POLLOUT; + use wasm_posix_shared::signal::SIGPIPE; + use wasm_posix_shared::socket::{MSG_NOSIGNAL, SHUT_RD}; + + let recv_path = b"/tmp/udg-shutdown-recv.sock"; + let send_path = b"/tmp/udg-shutdown-send.sock"; + let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); + let (send_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, send_path); + sys_connect(&mut proc, &mut host, send_fd, &recv_addr).unwrap(); + + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT { + sys_send( + &mut proc, + &mut host, + send_fd, + &(sequence as u32).to_le_bytes(), + 0, + ) + .unwrap(); + } + let recv_idx = test_socket_idx(&proc, recv_fd); + let mut pollfd = WasmPollFd { + fd: send_fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 0, + ); + + sys_shutdown(&mut proc, &mut host, recv_fd, SHUT_RD).unwrap(); + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(pollfd.revents & POLLOUT, 0); + assert_eq!( + sys_send(&mut proc, &mut host, send_fd, b"signal", 0).unwrap_err(), + Errno::EPIPE, + ); + assert!(proc.signals.is_pending(SIGPIPE)); + assert_eq!( + proc.sockets.get(recv_idx).unwrap().dgram_queue.len(), + UDP_DATAGRAM_QUEUE_LIMIT, + ); + proc.signals.clear(SIGPIPE); + assert_eq!( + sys_send( + &mut proc, + &mut host, + send_fd, + b"quiet", + MSG_NOSIGNAL, + ) + .unwrap_err(), + Errno::EPIPE, + ); + assert!(!proc.signals.is_pending(SIGPIPE)); - #[test] - fn test_open_dev_null() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/null", O_RDWR, 0).unwrap(); - assert!(fd >= 3); - let ofd = proc - .ofd_table - .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) - .unwrap(); - assert_eq!(ofd.file_type, FileType::CharDevice); - assert_eq!(ofd.host_handle, -1); + for fd in [send_fd, recv_fd] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } + for path in [send_path.as_slice(), recv_path.as_slice()] { + sys_unlink(&mut proc, &mut host, path).unwrap(); + } } #[test] - fn test_open_dev_zero() { - let mut proc = Process::new(1); + fn test_unix_dgram_close_releases_full_connected_sender() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9055); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/zero", O_RDONLY, 0).unwrap(); - let ofd = proc - .ofd_table - .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) + use wasm_posix_shared::poll::POLLOUT; + + let recv_path = b"/tmp/udg-close-recv.sock"; + let send_path = b"/tmp/udg-close-send.sock"; + let (recv_fd, recv_addr) = bind_test_unix_dgram(&mut proc, &mut host, recv_path); + let (send_fd, _) = bind_test_unix_dgram(&mut proc, &mut host, send_path); + sys_connect(&mut proc, &mut host, send_fd, &recv_addr).unwrap(); + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT { + sys_send( + &mut proc, + &mut host, + send_fd, + &(sequence as u32).to_le_bytes(), + 0, + ) .unwrap(); - assert_eq!(ofd.host_handle, -2); + } + + let mut pollfd = WasmPollFd { + fd: send_fd, + events: POLLOUT, + revents: 0, + }; + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 0, + ); + sys_close(&mut proc, &mut host, recv_fd).unwrap(); + assert_eq!( + sys_poll( + &mut proc, + &mut host, + core::slice::from_mut(&mut pollfd), + 0, + ) + .unwrap(), + 1, + ); + assert_ne!(pollfd.revents & POLLOUT, 0); + assert_eq!( + sys_send(&mut proc, &mut host, send_fd, b"closed", 0).unwrap_err(), + Errno::ECONNREFUSED, + ); + + sys_close(&mut proc, &mut host, send_fd).unwrap(); + for path in [send_path.as_slice(), recv_path.as_slice()] { + sys_unlink(&mut proc, &mut host, path).unwrap(); + } } #[test] - fn test_open_dev_urandom() { - let mut proc = Process::new(1); + fn test_unix_dgram_rejects_stream_target() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9026); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/urandom", O_RDONLY, 0).unwrap(); - let ofd = proc - .ofd_table - .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) - .unwrap(); - assert_eq!(ofd.host_handle, -3); + use wasm_posix_shared::socket::*; + + let stream_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 64]; + addr[0] = 1; + let path = b"/tmp/udg-stream-target.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + let addr = &addr[..2 + path.len() + 1]; + sys_bind(&mut proc, &mut host, stream_fd, addr).unwrap(); + + let dgram_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_connect(&mut proc, &mut host, dgram_fd, addr).unwrap_err(), + Errno::ECONNREFUSED, + ); + assert_eq!( + sys_sendto(&mut proc, &mut host, dgram_fd, b"wrong type", 0, addr).unwrap_err(), + Errno::ECONNREFUSED, + ); + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9026); } #[test] - fn test_open_dev_full() { - let mut proc = Process::new(1); + fn test_unix_dgram_cross_process_target_cannot_misindex_sender_socket() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut receiver = Process::new(9027); + let mut sender = Process::new(9028); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/full", O_RDWR, 0).unwrap(); - let ofd = proc - .ofd_table - .get(proc.fd_table.get(fd).unwrap().ofd_ref.0) - .unwrap(); - assert_eq!(ofd.host_handle, -4); + use wasm_posix_shared::socket::*; + + let recv_fd = sys_socket(&mut receiver, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut addr = [0u8; 64]; + addr[0] = 1; + let path = b"/tmp/udg-cross-process.sock"; + addr[2..2 + path.len()].copy_from_slice(path); + let addr = &addr[..2 + path.len() + 1]; + sys_bind(&mut receiver, &mut host, recv_fd, addr).unwrap(); + + // This is slot zero in each process. Discarding the registry PID would + // enqueue into the sender's unrelated slot-zero socket. + let send_fd = sys_socket(&mut sender, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + assert_eq!( + sys_sendto(&mut sender, &mut host, send_fd, b"must not misdeliver", 0, addr) + .unwrap_err(), + Errno::ECONNREFUSED, + ); + let send_entry = sender.fd_table.get(send_fd).unwrap(); + let send_ofd = sender.ofd_table.get(send_entry.ofd_ref.0).unwrap(); + let send_idx = (-(send_ofd.host_handle + 1)) as usize; + assert!(sender.sockets.get(send_idx).unwrap().dgram_queue.is_empty()); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9027); + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9028); } #[test] - fn test_open_dev_fd_dups_existing() { - let mut proc = Process::new(1); + fn test_unix_dgram_close_cannot_redirect_connected_peer_after_slot_reuse() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9039); let mut host = MockHostIO::new(); - // fd 1 = stdout, opening /dev/fd/1 should dup it - let fd = sys_open(&mut proc, &mut host, b"/dev/fd/1", O_WRONLY, 0).unwrap(); - assert!(fd >= 3); - let ofd_ref_1 = proc.fd_table.get(1).unwrap().ofd_ref.0; - let ofd_ref_new = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert_eq!(ofd_ref_1, ofd_ref_new); + use wasm_posix_shared::socket::*; + + let server_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let mut server_addr = [0u8; 64]; + server_addr[0] = AF_UNIX as u8; + let server_path = b"/tmp/udg-closed-peer.sock"; + server_addr[2..2 + server_path.len()].copy_from_slice(server_path); + let server_addr = &server_addr[..2 + server_path.len() + 1]; + sys_bind(&mut proc, &mut host, server_fd, server_addr).unwrap(); + + let server_entry = proc.fd_table.get(server_fd).unwrap(); + let server_ofd = proc.ofd_table.get(server_entry.ofd_ref.0).unwrap(); + let server_idx = (-(server_ofd.host_handle + 1)) as usize; + + let client_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client_fd, server_addr).unwrap(); + sys_close(&mut proc, &mut host, server_fd).unwrap(); + + let replacement_fd = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + let replacement_entry = proc.fd_table.get(replacement_fd).unwrap(); + let replacement_ofd = proc.ofd_table.get(replacement_entry.ofd_ref.0).unwrap(); + let replacement_idx = (-(replacement_ofd.host_handle + 1)) as usize; + assert_eq!(replacement_idx, server_idx); + + let mut replacement_addr = [0u8; 64]; + replacement_addr[0] = AF_UNIX as u8; + let replacement_path = b"/tmp/udg-replacement.sock"; + replacement_addr[2..2 + replacement_path.len()].copy_from_slice(replacement_path); + let replacement_addr = &replacement_addr[..2 + replacement_path.len() + 1]; + sys_bind(&mut proc, &mut host, replacement_fd, replacement_addr).unwrap(); + + assert_eq!( + sys_write(&mut proc, &mut host, client_fd, b"must not redirect").unwrap_err(), + Errno::ECONNREFUSED, + ); + assert!(proc + .sockets + .get(replacement_idx) + .unwrap() + .dgram_queue + .is_empty()); + + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9039); } #[test] - fn test_open_dev_stdin_alias() { - let mut proc = Process::new(1); + fn test_abstract_unix_name_is_reusable_after_last_close() { + let _lock = UNIX_REGISTRY_LOCK.lock().unwrap(); + let mut proc = Process::new(9029); let mut host = MockHostIO::new(); - let fd = sys_open(&mut proc, &mut host, b"/dev/stdin", O_RDONLY, 0).unwrap(); - let ofd_ref_0 = proc.fd_table.get(0).unwrap().ofd_ref.0; - let ofd_ref_new = proc.fd_table.get(fd).unwrap().ofd_ref.0; - assert_eq!(ofd_ref_0, ofd_ref_new); + use wasm_posix_shared::socket::*; + + let addr = [1, 0, 0, b'r', b'e', b'u', b's', b'e']; + let first = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_bind(&mut proc, &mut host, first, &addr).unwrap(); + sys_close(&mut proc, &mut host, first).unwrap(); + + let second = sys_socket(&mut proc, &mut host, AF_UNIX, SOCK_DGRAM, 0).unwrap(); + sys_bind(&mut proc, &mut host, second, &addr).unwrap(); + unsafe { crate::unix_socket::global_unix_socket_registry() }.cleanup_process(9029); } #[test] - fn test_open_dev_fd_nonexistent() { + fn test_inet6_loopback_bind_getsockname_and_connect_refused() { let mut proc = Process::new(1); let mut host = MockHostIO::new(); - let result = sys_open(&mut proc, &mut host, b"/dev/fd/999", O_RDONLY, 0); - assert_eq!(result, Err(Errno::EBADF)); - } + use wasm_posix_shared::socket::*; - #[test] - fn test_match_virtual_device() { - assert_eq!( - match_virtual_device(b"/dev/null"), - Some(VirtualDevice::Null) - ); + let fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + let mut addr = [0u8; 28]; + addr[0] = 10; // AF_INET6 + addr[2] = 0x05; + addr[3] = 0x39; // port 1337 + addr[23] = 1; // ::1 + + sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); + sys_listen(&mut proc, &mut host, fd, 5).unwrap(); + + let mut name = [0u8; 28]; + let n = sys_getsockname(&proc, fd, &mut name).unwrap(); + assert_eq!(n, 28); + assert_eq!(&name[..28], &addr); + + let client_fd = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + let mut refused = [0u8; 28]; + refused[0] = 10; + refused[3] = 1; // ::1:1, no listener + refused[23] = 1; assert_eq!( - match_virtual_device(b"/dev/console"), - Some(VirtualDevice::Null) + sys_connect(&mut proc, &mut host, client_fd, &refused).unwrap_err(), + Errno::ECONNREFUSED, ); + + let any_client = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + let mut unspecified_peer = addr; + unspecified_peer[8..24].fill(0); + sys_connect(&mut proc, &mut host, any_client, &unspecified_peer).unwrap(); + let mut peer = [0u8; 28]; + assert_eq!(sys_getpeername(&proc, any_client, &mut peer).unwrap(), 28); + assert_eq!(&peer[8..24], &addr[8..24]); + let accepted = sys_accept(&mut proc, &mut host, fd).unwrap(); assert_eq!( - match_virtual_device(b"/dev/zero"), - Some(VirtualDevice::Zero) + sys_send(&mut proc, &mut host, any_client, b"v6-request", 0).unwrap(), + 10, ); + let mut payload = [0u8; 16]; assert_eq!( - match_virtual_device(b"/dev/urandom"), - Some(VirtualDevice::Urandom) + sys_recv(&mut proc, &mut host, accepted, &mut payload, 0).unwrap(), + 10, ); + assert_eq!(&payload[..10], b"v6-request"); assert_eq!( - match_virtual_device(b"/dev/random"), - Some(VirtualDevice::Urandom) + sys_send(&mut proc, &mut host, accepted, b"v6-reply", 0).unwrap(), + 8, ); assert_eq!( - match_virtual_device(b"/dev/full"), - Some(VirtualDevice::Full) + sys_recv(&mut proc, &mut host, any_client, &mut payload, 0).unwrap(), + 8, ); - assert_eq!(match_virtual_device(b"/dev/tty"), None); - assert_eq!(match_virtual_device(b"/tmp/foo"), None); - } - - #[test] - fn test_match_dev_fd() { - assert_eq!(match_dev_fd(b"/dev/stdin"), Some(0)); - assert_eq!(match_dev_fd(b"/dev/stdout"), Some(1)); - assert_eq!(match_dev_fd(b"/dev/stderr"), Some(2)); - assert_eq!(match_dev_fd(b"/dev/fd/0"), Some(0)); - assert_eq!(match_dev_fd(b"/dev/fd/5"), Some(5)); - assert_eq!(match_dev_fd(b"/dev/fd/123"), Some(123)); - assert_eq!(match_dev_fd(b"/dev/fd/"), None); - assert_eq!(match_dev_fd(b"/dev/fd/abc"), None); - assert_eq!(match_dev_fd(b"/tmp/foo"), None); - } + assert_eq!(&payload[..8], b"v6-reply"); - #[test] - fn test_virtual_device_roundtrip() { - for dev in [ - VirtualDevice::Null, - VirtualDevice::Zero, - VirtualDevice::Urandom, - VirtualDevice::Full, - VirtualDevice::Fb0, - VirtualDevice::Mice, - VirtualDevice::Dsp, - VirtualDevice::DriRenderD128, - VirtualDevice::DriCard0, - ] { - assert_eq!( - VirtualDevice::from_host_handle(dev.host_handle()), - Some(dev) - ); + for open_fd in [accepted, any_client, client_fd, fd] { + sys_close(&mut proc, &mut host, open_fd).unwrap(); } - assert_eq!(VirtualDevice::from_host_handle(0), None); - // First sentinel past the allocated range — must not roundtrip. - assert_eq!(VirtualDevice::from_host_handle(-10), None); } - // ===== Loopback socket tests ===== - #[test] - fn test_bind_inet_ephemeral_port() { - let mut proc = Process::new(1); + fn test_dual_stack_wildcard_accepts_ipv4_as_mapped_ipv6() { + let mut proc = Process::new(9030); let mut host = MockHostIO::new(); use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); - // Bind with port=0 → ephemeral - let addr = [2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]; - sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); - // getsockname should show the assigned ephemeral port - let mut buf = [0u8; 16]; - let n = sys_getsockname(&proc, fd, &mut buf).unwrap(); - assert_eq!(n, 16); - assert_eq!(buf[0], 2); // AF_INET - let port = u16::from_be_bytes([buf[2], buf[3]]); - assert!( - port >= 49152, - "ephemeral port should be >= 49152, got {}", - port + + let port = 23101u16; + let mut v6_any = [0u8; 28]; + v6_any[0] = AF_INET6 as u8; + v6_any[2..4].copy_from_slice(&port.to_be_bytes()); + let server6 = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + assert_eq!( + sys_getsockopt(&mut proc, server6, IPPROTO_IPV6, IPV6_V6ONLY).unwrap(), + 0 ); - } + sys_bind(&mut proc, &mut host, server6, &v6_any).unwrap(); + sys_listen(&mut proc, &mut host, server6, 4).unwrap(); - #[test] - fn test_getsockname_inet_explicit_port() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; - let fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - // Bind to port 8080 - let mut addr = [0u8; 16]; - addr[0] = 2; - addr[2] = 0x1F; - addr[3] = 0x90; // 8080 big-endian - addr[4] = 127; - addr[5] = 0; - addr[6] = 0; - addr[7] = 1; - sys_bind(&mut proc, &mut host, fd, &addr).unwrap(); - let mut buf = [0u8; 16]; - sys_getsockname(&proc, fd, &mut buf).unwrap(); - assert_eq!(buf[0], 2); // AF_INET - let port = u16::from_be_bytes([buf[2], buf[3]]); - assert_eq!(port, 8080); - assert_eq!(&buf[4..8], &[127, 0, 0, 1]); - } + let mut v4_any = [0u8; 16]; + v4_any[0] = AF_INET as u8; + v4_any[2..4].copy_from_slice(&port.to_be_bytes()); + let conflicting4 = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + assert_eq!( + sys_bind(&mut proc, &mut host, conflicting4, &v4_any).unwrap_err(), + Errno::EADDRINUSE, + ); - #[test] - fn test_tcp_loopback() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); - use wasm_posix_shared::socket::*; + let client4 = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut loop4 = v4_any; + loop4[4..8].copy_from_slice(&[127, 0, 0, 1]); + sys_connect(&mut proc, &mut host, client4, &loop4).unwrap(); + let accepted = sys_accept(&mut proc, &mut host, server6).unwrap(); + let accepted_entry = proc.fd_table.get(accepted).unwrap(); + let accepted_ofd = proc.ofd_table.get(accepted_entry.ofd_ref.0).unwrap(); + let accepted_idx = (-(accepted_ofd.host_handle + 1)) as usize; + let accepted_sock = proc.sockets.get(accepted_idx).unwrap(); + assert_eq!(accepted_sock.domain, crate::socket::SocketDomain::Inet6); + assert_eq!( + accepted_sock.peer_addr6, + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 127, 0, 0, 1] + ); - // Server: socket → bind → listen - let server_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let mut addr = [0u8; 16]; - addr[0] = 2; // AF_INET - addr[2] = 0x1F; - addr[3] = 0x90; // port 8080 - sys_bind(&mut proc, &mut host, server_fd, &addr).unwrap(); - sys_listen(&mut proc, &mut host, server_fd, 5).unwrap(); + sys_close(&mut proc, &mut host, accepted).unwrap(); + sys_close(&mut proc, &mut host, client4).unwrap(); + sys_close(&mut proc, &mut host, conflicting4).unwrap(); + sys_close(&mut proc, &mut host, server6).unwrap(); + } - // Client: socket → connect to 127.0.0.1:8080 - let client_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); - let mut connect_addr = [0u8; 16]; - connect_addr[0] = 2; - connect_addr[2] = 0x1F; - connect_addr[3] = 0x90; // port 8080 - connect_addr[4] = 127; - connect_addr[5] = 0; - connect_addr[6] = 0; - connect_addr[7] = 1; - sys_connect(&mut proc, &mut host, client_fd, &connect_addr).unwrap(); + #[test] + fn test_v6only_wildcard_isolated_from_ipv4_and_reserves_ipv6() { + let mut proc = Process::new(9031); + let mut host = MockHostIO::new(); + use wasm_posix_shared::socket::*; - // Server: accept - let accepted_fd = sys_accept(&mut proc, &mut host, server_fd).unwrap(); + let port = 23102u16; + let mut v6_any = [0u8; 28]; + v6_any[0] = AF_INET6 as u8; + v6_any[2..4].copy_from_slice(&port.to_be_bytes()); + let server6 = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + sys_setsockopt(&mut proc, server6, IPPROTO_IPV6, IPV6_V6ONLY, 1).unwrap(); + sys_bind(&mut proc, &mut host, server6, &v6_any).unwrap(); + sys_listen(&mut proc, &mut host, server6, 4).unwrap(); - // Client writes, server reads - let written = sys_write(&mut proc, &mut host, client_fd, b"hello TCP").unwrap(); - assert_eq!(written, 9); + let duplicate6 = sys_socket(&mut proc, &mut host, AF_INET6, SOCK_STREAM, 0).unwrap(); + assert_eq!( + sys_bind(&mut proc, &mut host, duplicate6, &v6_any).unwrap_err(), + Errno::EADDRINUSE, + ); - let mut buf = [0u8; 64]; - let n = sys_read(&mut proc, &mut host, accepted_fd, &mut buf).unwrap(); - assert_eq!(&buf[..n], b"hello TCP"); + let mut v4_any = [0u8; 16]; + v4_any[0] = AF_INET as u8; + v4_any[2..4].copy_from_slice(&port.to_be_bytes()); + let refused4 = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + let mut loop4 = v4_any; + loop4[4..8].copy_from_slice(&[127, 0, 0, 1]); + assert_eq!( + sys_connect(&mut proc, &mut host, refused4, &loop4).unwrap_err(), + Errno::ECONNREFUSED, + ); - // Server writes, client reads - let written = sys_write(&mut proc, &mut host, accepted_fd, b"reply").unwrap(); - assert_eq!(written, 5); + let server4 = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + sys_bind(&mut proc, &mut host, server4, &v4_any).unwrap(); + sys_listen(&mut proc, &mut host, server4, 4).unwrap(); + let client4 = sys_socket(&mut proc, &mut host, AF_INET, SOCK_STREAM, 0).unwrap(); + sys_connect(&mut proc, &mut host, client4, &loop4).unwrap(); + let accepted4 = sys_accept(&mut proc, &mut host, server4).unwrap(); + let accepted_entry = proc.fd_table.get(accepted4).unwrap(); + let accepted_ofd = proc.ofd_table.get(accepted_entry.ofd_ref.0).unwrap(); + let accepted_idx = (-(accepted_ofd.host_handle + 1)) as usize; + assert_eq!( + proc.sockets.get(accepted_idx).unwrap().domain, + crate::socket::SocketDomain::Inet, + ); - let mut buf2 = [0u8; 64]; - let n2 = sys_read(&mut proc, &mut host, client_fd, &mut buf2).unwrap(); - assert_eq!(&buf2[..n2], b"reply"); + for fd in [accepted4, client4, server4, refused4, duplicate6, server6] { + sys_close(&mut proc, &mut host, fd).unwrap(); + } } #[test] - fn test_udp_loopback() { - let mut proc = Process::new(1); - let mut host = MockHostIO::new(); + fn test_udp_recv_queue_tail_drops_on_overflow() { use wasm_posix_shared::socket::*; - // Create and bind a UDP socket (receiver) - let recv_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); - let mut addr = [0u8; 16]; - addr[0] = 2; - // port=0 → ephemeral - sys_bind(&mut proc, &mut host, recv_fd, &addr).unwrap(); - - // Get the assigned port - let mut gsa_buf = [0u8; 16]; - sys_getsockname(&proc, recv_fd, &mut gsa_buf).unwrap(); - let port = u16::from_be_bytes([gsa_buf[2], gsa_buf[3]]); + let mut proc = Process::new(9051); + let mut host = MockHostIO::new(); - // Create and bind a sender UDP socket + // Receiver bound to 127.0.0.1:ephemeral. + let recv_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); + let mut baddr = [0u8; 16]; + baddr[0] = 2; + sys_bind(&mut proc, &mut host, recv_fd, &baddr).unwrap(); + let mut gsa = [0u8; 16]; + sys_getsockname(&proc, recv_fd, &mut gsa).unwrap(); + let port = [gsa[2], gsa[3]]; + + // Sender. let send_fd = sys_socket(&mut proc, &mut host, AF_INET, SOCK_DGRAM, 0).unwrap(); - let mut sender_addr = [0u8; 16]; - sender_addr[0] = 2; - sys_bind(&mut proc, &mut host, send_fd, &sender_addr).unwrap(); + let mut saddr = [0u8; 16]; + saddr[0] = 2; + sys_bind(&mut proc, &mut host, send_fd, &saddr).unwrap(); + + // Send LIMIT + 2 sequence-numbered datagrams to 127.0.0.1:port. + for sequence in 0..UDP_DATAGRAM_QUEUE_LIMIT + 2 { + let mut dest = [0u8; 16]; + dest[0] = 2; + dest[2] = port[0]; + dest[3] = port[1]; + dest[4] = 127; + dest[7] = 1; + assert_eq!( + sys_sendto( + &mut proc, + &mut host, + send_fd, + &(sequence as u32).to_le_bytes(), + 0, + &dest, + ) + .unwrap(), + 4, + ); + } + let recv_idx = { + let entry = proc.fd_table.get(recv_fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + udp_queue_datagram(proc.sockets.get_mut(recv_idx).unwrap(), || { + panic!("a full UDP queue must not construct a dropped payload") + }); - // Send to the receiver via loopback - let mut dest_addr = [0u8; 16]; - dest_addr[0] = 2; - let port_be = port.to_be_bytes(); - dest_addr[2] = port_be[0]; - dest_addr[3] = port_be[1]; - dest_addr[4] = 127; - dest_addr[5] = 0; - dest_addr[6] = 0; - dest_addr[7] = 1; - let n = sys_sendto(&mut proc, &mut host, send_fd, b"hello UDP", 0, &dest_addr).unwrap(); - assert_eq!(n, 9); + // The already-queued datagrams survive in order. + for expected in 0..UDP_DATAGRAM_QUEUE_LIMIT { + let mut buf = [0u8; 4]; + let mut from = [0u8; 16]; + let (n, from_len) = + sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap(); + assert_eq!(n, 4); + assert_eq!(from_len, 16); + assert_eq!(u32::from_le_bytes(buf), expected as u32); + } - // Receive - let mut buf = [0u8; 64]; - let mut from_addr = [0u8; 16]; - let (data_len, addr_len) = - sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from_addr).unwrap(); - assert_eq!(&buf[..data_len], b"hello UDP"); - assert_eq!(addr_len, 16); - assert_eq!(from_addr[0], 2); // AF_INET + // The two incoming datagrams sent after the queue filled were dropped. + let mut buf = [0u8; 4]; + let mut from = [0u8; 16]; + assert_eq!( + sys_recvfrom(&mut proc, &mut host, recv_fd, &mut buf, 0, &mut from).unwrap_err(), + Errno::EAGAIN, + ); + sys_close(&mut proc, &mut host, send_fd).unwrap(); + sys_close(&mut proc, &mut host, recv_fd).unwrap(); } // ── Threading tests ────────────────────────────────────────────── @@ -18177,12 +26218,18 @@ mod tests { let mut proc = Process::new(1); let mut host = MockHostIO::new(); let fd = sys_eventfd2(&mut proc, 42, 0).unwrap(); + let backing_idx = { + let entry = proc.fd_table.get(fd).unwrap(); + let ofd = proc.ofd_table.get(entry.ofd_ref.0).unwrap(); + (-(ofd.host_handle + 1)) as usize + }; + let backing_generation = + descriptor_backing_generation(FileType::EventFd, backing_idx).unwrap(); // Close the eventfd sys_close(&mut proc, &mut host, fd).unwrap(); - // eventfd slot should be freed - assert!(proc.eventfds[0].is_none()); + assert_descriptor_backing_released(FileType::EventFd, backing_idx, backing_generation); } #[test] @@ -18317,6 +26364,46 @@ mod tests { assert_eq!(vnsec, 0); } + #[test] + fn test_timerfd_settime_host_error_leaves_backing_unchanged() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let fd = sys_timerfd_create(&mut proc, 0, 0).unwrap(); + let backing_idx = descriptor_backing_idx(&proc, fd); + + sys_timerfd_settime(&mut proc, &mut host, fd, 1, 2, 3, 110, 4).unwrap(); + let before = crate::descriptor_backing::with_timerfds(|table| { + let timer = table.get(backing_idx).unwrap(); + ( + timer.interval_sec, + timer.interval_nsec, + timer.value_sec, + timer.value_nsec, + timer.expirations, + ) + }); + + host.clock_error = Some(Errno::EIO); + assert_eq!( + sys_timerfd_settime(&mut proc, &mut host, fd, 0, 9, 8, 7, 6), + Err(Errno::EIO) + ); + let after = crate::descriptor_backing::with_timerfds(|table| { + let timer = table.get(backing_idx).unwrap(); + ( + timer.interval_sec, + timer.interval_nsec, + timer.value_sec, + timer.value_nsec, + timer.expirations, + ) + }); + assert_eq!(after, before); + + host.clock_error = None; + sys_close(&mut proc, &mut host, fd).unwrap(); + } + #[test] fn test_timerfd_settime_absolute() { let mut proc = Process::new(1); @@ -18529,6 +26616,72 @@ mod tests { assert!(!proc.signals.is_pending(SIGINT)); } + #[test] + fn test_signalfd4_reads_main_directed_signal_without_exposing_it_to_workers() { + use wasm_posix_shared::signal::SIGXFSZ; + use wasm_posix_shared::WasmPollFd; + + let _guard = THREAD_IDENTITY_LOCK.lock().unwrap(); + set_test_current_tid(0); + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let mask = crate::signal::sig_bit(SIGXFSZ); + proc.signals.blocked = mask; + proc.add_thread(crate::process::ThreadInfo::new(2, 0, 0, 0)); + let fd = sys_signalfd4(&mut proc, -1, mask, O_NONBLOCK).unwrap(); + assert!(proc.raise_for_thread(proc.pid, SIGXFSZ)); + assert!(!proc.signal_pending_for(2, SIGXFSZ)); + + let mut pollfd = WasmPollFd { + fd, + events: POLLIN, + revents: 0, + }; + assert_eq!( + sys_poll(&mut proc, &mut host, core::slice::from_mut(&mut pollfd), 0), + Ok(1) + ); + assert_ne!(pollfd.revents & POLLIN, 0); + + let mut buf = [0u8; 128]; + assert_eq!(sys_read(&mut proc, &mut host, fd, &mut buf), Ok(128)); + assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), SIGXFSZ); + assert!(!proc.signal_pending_for(proc.pid, SIGXFSZ)); + } + + #[test] + fn test_signalfd4_accepts_timer_metadata_and_overrun() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + proc.posix_timers.push(Some(crate::process::PosixTimerState { + clock_id: 1, + sigev_signo: 10, + sigev_value: 77, + sigev_notify: 0, + sigev_tid: 0, + interval_sec: 0, + interval_nsec: 1, + value_sec: 0, + value_nsec: 1, + notification_pending: true, + overrun_current: 3, + overrun_last: 0, + })); + proc.signals.raise_timer(10, 77, 0); + let fd = sys_signalfd4(&mut proc, -1, crate::signal::sig_bit(10), O_NONBLOCK).unwrap(); + let mut buf = [0u8; 128]; + + assert_eq!(sys_read(&mut proc, &mut host, fd, &mut buf), Ok(128)); + assert_eq!(u32::from_le_bytes(buf[0..4].try_into().unwrap()), 10); + assert_eq!(i32::from_le_bytes(buf[8..12].try_into().unwrap()), -2); + assert_eq!(u32::from_le_bytes(buf[24..28].try_into().unwrap()), 0); + assert_eq!(i32::from_le_bytes(buf[32..36].try_into().unwrap()), 3); + assert_eq!(i32::from_le_bytes(buf[44..48].try_into().unwrap()), 77); + let timer = proc.posix_timers[0].as_ref().unwrap(); + assert!(!timer.notification_pending); + assert_eq!(timer.overrun_last, 3); + } + #[test] fn test_signalfd4_read_small_buf() { let mut proc = Process::new(1); @@ -19757,6 +27910,29 @@ mod tests { // in `host/src/kernel-worker.ts`'s SYS_MREMAP post-syscall fixup. } + #[test] + fn test_mremap_rejects_unsupported_flags() { + let mut proc = Process::new(1); + use wasm_posix_shared::mmap::{MAP_ANONYMOUS, MAP_PRIVATE, PROT_READ, PROT_WRITE}; + let addr = proc.memory.mmap_anonymous( + 0, + 0x10000, + PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, + ); + + // Linux MREMAP_FIXED requires the fifth syscall argument (new_addr), + // which the current wasm libc import ABI does not pass through. The + // kernel must reject it rather than accidentally treating the request + // as an in-place grow at old_addr. + assert_eq!( + sys_mremap(&mut proc, addr, 0x10000, 0x20000, 2).unwrap_err(), + Errno::EINVAL + ); + assert!(proc.memory.is_mapped(addr)); + assert!(!proc.memory.is_mapped(addr + 0x10000)); + } + // ---- O_CLOFORK / FD_CLOFORK tests ---- #[test] @@ -19952,10 +28128,84 @@ mod tests { let st = sys_stat(&mut proc, &mut host, b"/etc/mtab").unwrap(); assert_eq!(st.st_mode & S_IFMT, S_IFREG); assert_eq!(st.st_size as usize, crate::procfs::MOUNTS_CONTENT.len()); + assert!(sys_access(&mut proc, &mut host, b"/etc/mtab", R_OK).is_ok()); + assert_eq!( + sys_access(&mut proc, &mut host, b"/etc/mtab", W_OK), + Err(Errno::EACCES), + ); + + sys_close(&mut proc, &mut host, fd).unwrap(); + } + + #[test] + fn kernel_owned_namespaces_do_not_fall_through_to_hidden_host_entries() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_missing_path(b"/proc/new"); + host.set_missing_path(b"/dev/new"); + + assert_eq!( + sys_open( + &mut proc, + &mut host, + b"/proc/new", + O_CREAT | O_WRONLY, + 0o600, + ), + Err(Errno::EROFS), + ); + assert_eq!( + sys_mkdir(&mut proc, &mut host, b"/dev/new", 0o755), + Err(Errno::EROFS), + ); + assert_eq!( + sys_unlink(&mut proc, &mut host, b"/dev/null"), + Err(Errno::EROFS), + ); + assert_eq!( + sys_unlink(&mut proc, &mut host, b"/etc/mtab"), + Err(Errno::EROFS), + ); + assert!(sys_stat(&mut proc, &mut host, b"/etc/mtab").is_ok()); + } + #[test] + fn dev_shm_uses_writable_host_mount_metadata() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/dev/shm", 0, 0, 0o1777); + host.set_missing_path(b"/dev/shm/object"); + + let stat = sys_stat(&mut proc, &mut host, b"/dev/shm").unwrap(); + assert_eq!(stat.st_mode & 0o7777, 0o1777); + let fd = sys_open( + &mut proc, + &mut host, + b"/dev/shm/object", + O_CREAT | O_RDWR, + 0o600, + ) + .unwrap(); sys_close(&mut proc, &mut host, fd).unwrap(); } + #[test] + fn test_static_ssl_files_are_owned_by_the_host_vfs() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + host.set_dir_with_owner(b"/etc/ssl", 0, 0, 0o755); + for path in [ + b"/etc/ssl/cert.pem".as_slice(), + b"/etc/ssl/openssl.cnf".as_slice(), + ] { + host.set_missing_path(path); + assert_eq!( + sys_stat(&mut proc, &mut host, path).unwrap_err(), + Errno::ENOENT, + ); + } + } + #[test] fn test_statfs_validates_path() { let mut proc = Process::new(1); @@ -19988,6 +28238,21 @@ mod tests { assert_eq!(&buf[..n], b"42"); } + #[test] + fn test_procfs_thread_self_text_and_component_resolution() { + let mut proc = Process::new(42); + let mut host = MockHostIO::new(); + let mut buf = [0u8; 64]; + + let n = sys_readlink(&mut proc, &mut host, b"/proc/thread-self", &mut buf).unwrap(); + assert_eq!(&buf[..n], b"42/task/42"); + + let stat = sys_stat(&mut proc, &mut host, b"/proc/thread-self/status").unwrap(); + assert_eq!(stat.st_mode & S_IFMT, S_IFREG); + let dir = sys_stat(&mut proc, &mut host, b"/proc/thread-self").unwrap(); + assert_eq!(dir.st_mode & S_IFMT, S_IFDIR); + } + #[test] fn test_procfs_readlink_cwd() { let mut proc = Process::new(1); @@ -20057,6 +28322,78 @@ mod tests { sys_access(&mut proc, &mut host, b"/proc/self/stat", 2).unwrap_err(), Errno::EACCES, ); + assert_eq!( + sys_access(&mut proc, &mut host, b"/proc/self/stat", X_OK), + Err(Errno::EACCES), + ); + } + + #[test] + fn test_procfs_metadata_rejects_missing_pid_scopes() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + let missing_paths: &[&[u8]] = &[ + b"/proc/0", + b"/proc/999", + b"/proc/999/stat", + b"/proc/999/net", + b"/proc/999/net/tcp", + ]; + + for path in missing_paths { + assert_eq!( + sys_stat(&mut proc, &mut host, path).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_lstat(&mut proc, &mut host, path).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_access(&mut proc, &mut host, path, 0), + Err(Errno::ENOENT) + ); + assert_eq!( + sys_fstatat(&mut proc, &mut host, AT_FDCWD, path, 0).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_faccessat(&mut proc, &mut host, AT_FDCWD, path, 0, 0), + Err(Errno::ENOENT) + ); + let mut link_buf = [0u8; 64]; + assert_eq!( + sys_readlink(&mut proc, &mut host, path, &mut link_buf).unwrap_err(), + Errno::ENOENT + ); + assert_eq!( + sys_readlinkat(&mut proc, &mut host, AT_FDCWD, path, &mut link_buf).unwrap_err(), + Errno::ENOENT + ); + } + + assert_eq!( + sys_chdir(&mut proc, &mut host, b"/proc/999"), + Err(Errno::ENOENT) + ); + assert_eq!( + sys_chdir(&mut proc, &mut host, b"/proc/999/net"), + Err(Errno::ENOENT) + ); + } + + #[test] + fn test_procfs_metadata_accepts_global_and_live_pid_net_paths() { + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + + for path in [b"/proc/net".as_slice(), b"/proc/1/net".as_slice()] { + let st = sys_stat(&mut proc, &mut host, path).unwrap(); + assert_eq!(st.st_mode & S_IFMT, S_IFDIR); + } + + let st = sys_stat(&mut proc, &mut host, b"/proc/1/net/tcp").unwrap(); + assert_eq!(st.st_mode & S_IFMT, S_IFREG); } #[test] @@ -20393,7 +28730,7 @@ mod tests { } #[test] - fn execve_releases_fb_binding_and_unbinds() { + fn execve_unbinds_fb_mapping_but_keeps_open_fd_owner() { use core::sync::atomic::Ordering; use wasm_posix_shared::flags::O_RDWR; use wasm_posix_shared::mmap::{MAP_SHARED, PROT_READ, PROT_WRITE}; @@ -20418,6 +28755,11 @@ mod tests { sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); assert!(proc.fb_binding.is_none()); assert_eq!(host.unbind_framebuffer_calls, alloc::vec![proc.pid as i32]); + assert_eq!( + crate::process_table::FB0_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); + sys_close(&mut proc, &mut host, fd).unwrap(); assert_eq!(crate::process_table::FB0_OWNER.load(Ordering::SeqCst), -1); } @@ -20580,7 +28922,7 @@ mod tests { } #[test] - fn exec_clears_mice_owner() { + fn exec_keeps_mice_owner_and_queue_for_open_fd() { use core::sync::atomic::Ordering; let _g = MICE_OWNER_LOCK.lock().unwrap(); reset_mice_state(); @@ -20595,11 +28937,53 @@ mod tests { crate::mouse::inject_event(2, 2, 0); sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); - assert_eq!(crate::mouse::MICE_OWNER.load(Ordering::SeqCst), -1); + assert_eq!( + crate::mouse::MICE_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); assert!( - !crate::mouse::has_data(), - "exec should reset the queue when releasing ownership" + crate::mouse::has_data(), + "exec should retain packets behind a surviving open fd" + ); + sys_close(&mut proc, &mut host, _fd).unwrap(); + } + + #[test] + fn exec_keeps_device_state_through_surviving_high_fd_alias() { + use core::sync::atomic::Ordering; + let _g = MICE_OWNER_LOCK.lock().unwrap(); + reset_mice_state(); + + let mut proc = Process::new(1); + let mut host = MockHostIO::new(); + sys_setrlimit(&mut proc, 7, 4096, 4096).unwrap(); + let cloexec_fd = + sys_open(&mut proc, &mut host, b"/dev/input/mice", O_RDONLY, 0).unwrap(); + proc.fd_table.get_mut(cloexec_fd).unwrap().fd_flags = FD_CLOEXEC; + let retained_fd = sys_fcntl(&mut proc, cloexec_fd, F_DUPFD, 2048).unwrap(); + assert_eq!(retained_fd, 2048); + + crate::mouse::inject_event(7, -3, 1); + let pid = proc.pid; + commit_exec_state(&mut proc, &mut host, pid).unwrap(); + + assert!(proc.fd_table.get(cloexec_fd).is_err()); + assert!(proc.fd_table.get(retained_fd).is_ok()); + assert_eq!( + crate::mouse::MICE_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); + let mut packet = [0u8; 3]; + assert_eq!( + sys_read(&mut proc, &mut host, retained_fd, &mut packet), + Ok(3) ); + assert_eq!(packet[1] as i8, 7); + assert_eq!(packet[2] as i8, -3); + + sys_close(&mut proc, &mut host, retained_fd).unwrap(); + assert_eq!(crate::mouse::MICE_OWNER.load(Ordering::SeqCst), -1); + assert!(!crate::mouse::has_data()); } // ----------------------------------------------------------------- @@ -20802,7 +29186,7 @@ mod tests { } #[test] - fn exec_clears_dsp_owner() { + fn exec_keeps_dsp_owner_and_ring_for_open_fd() { use core::sync::atomic::Ordering; let _g = DSP_OWNER_LOCK.lock().unwrap(); reset_dsp_state(); @@ -20817,12 +29201,16 @@ mod tests { sys_write(&mut proc, &mut host, _fd, &[5, 5, 5, 5]).unwrap(); sys_execve(&mut proc, &mut host, b"/bin/sh").unwrap(); - assert_eq!(crate::audio::DSP_OWNER.load(Ordering::SeqCst), -1); + assert_eq!( + crate::audio::DSP_OWNER.load(Ordering::SeqCst), + proc.pid as i32 + ); assert_eq!( crate::audio::pending_bytes(), - 0, - "exec should drain the ring when releasing ownership" + 4, + "exec should retain samples behind a surviving open fd" ); + sys_close(&mut proc, &mut host, _fd).unwrap(); } // ----------------------------------------------------------------- diff --git a/crates/kernel/src/terminal.rs b/crates/kernel/src/terminal.rs index e0a8d01d8c..f2343d4008 100644 --- a/crates/kernel/src/terminal.rs +++ b/crates/kernel/src/terminal.rs @@ -178,6 +178,14 @@ impl TerminalState { self.c_lflag & ICANON != 0 } + #[inline] + fn control_char_matches(&self, index: usize, byte: u8) -> bool { + // Kandelo's guest headers define _POSIX_VDISABLE as NUL. A zero + // control-character slot is disabled and must not make an input NUL + // act as VINTR/VEOL/etc. + self.c_cc[index] != 0 && self.c_cc[index] == byte + } + /// Process a byte through the line discipline. /// Returns echo bytes and an optional signal number if ISIG matched. /// ISIG is checked independently of ICANON — signal characters generate @@ -201,11 +209,11 @@ impl TerminalState { // This works in both canonical and raw modes. if self.c_lflag & ISIG != 0 { use wasm_posix_shared::signal; - let sig = if byte == self.c_cc[VINTR] { + let sig = if self.control_char_matches(VINTR, byte) { Some(signal::SIGINT) - } else if byte == self.c_cc[VQUIT] { + } else if self.control_char_matches(VQUIT, byte) { Some(signal::SIGQUIT) - } else if byte == self.c_cc[VSUSP] { + } else if self.control_char_matches(VSUSP, byte) { Some(signal::SIGTSTP) } else { None @@ -233,7 +241,7 @@ impl TerminalState { } // Check for VERASE (backspace/delete) - if byte == self.c_cc[VERASE] { + if self.control_char_matches(VERASE, byte) { if !self.line_buffer.is_empty() { self.line_buffer.pop(); if do_echo && self.c_lflag & ECHOE != 0 { @@ -245,7 +253,7 @@ impl TerminalState { } // Check for VKILL (kill line, ^U) - if byte == self.c_cc[VKILL] { + if self.control_char_matches(VKILL, byte) { if do_echo && self.c_lflag & ECHOK != 0 { // Erase the whole line from display for _ in 0..self.line_buffer.len() { @@ -257,7 +265,7 @@ impl TerminalState { } // Check for VEOF (^D) - if byte == self.c_cc[VEOF] { + if self.control_char_matches(VEOF, byte) { // Flush current line buffer without adding the EOF character self.cooked_buffer.extend_from_slice(&self.line_buffer); self.line_buffer.clear(); @@ -265,7 +273,7 @@ impl TerminalState { } // Newline or VEOL: complete the line - if byte == b'\n' || byte == self.c_cc[VEOL] { + if byte == b'\n' || self.control_char_matches(VEOL, byte) { self.line_buffer.push(byte); self.cooked_buffer.extend_from_slice(&self.line_buffer); self.line_buffer.clear(); @@ -352,6 +360,20 @@ mod tests { assert_eq!(&buf[..n], b"hello\n"); } + #[test] + fn disabled_zero_control_char_does_not_consume_input_nul() { + let mut ts = TerminalState::new(); + + let (_, signal) = ts.process_input_byte(0); + assert_eq!(signal, None); + assert!(!ts.has_cooked_data()); + + ts.process_input_byte(b'\n'); + let mut buf = [0u8; 4]; + let n = ts.read_cooked(&mut buf); + assert_eq!(&buf[..n], b"\0\n"); + } + #[test] fn test_line_buffer_cr_to_nl() { let mut ts = TerminalState::new(); diff --git a/crates/kernel/src/unix_socket.rs b/crates/kernel/src/unix_socket.rs index 499ebdb699..576feeacd9 100644 --- a/crates/kernel/src/unix_socket.rs +++ b/crates/kernel/src/unix_socket.rs @@ -17,6 +17,10 @@ pub struct UnixSocketEntry { pub pid: u32, /// Index into that process's SocketTable. pub sock_idx: usize, + /// Every process-local socket table entry that inherited this bound + /// endpoint. The public pid/sock_idx pair above is the current lookup + /// target; ownership moves to another live entry when that owner closes. + owners: Vec<(u32, usize)>, } /// Global registry mapping resolved paths to listening Unix sockets. @@ -37,13 +41,89 @@ impl UnixSocketRegistry { if self.entries.contains_key(&path) { return false; } - self.entries.insert(path, UnixSocketEntry { pid, sock_idx }); + self.entries.insert( + path, + UnixSocketEntry { + pid, + sock_idx, + owners: alloc::vec![(pid, sock_idx)], + }, + ); true } + /// Record a fork/spawn child that inherited a bound AF_UNIX endpoint. + pub fn add_owner(&mut self, path: &[u8], pid: u32, sock_idx: usize) -> bool { + let Some(entry) = self.entries.get_mut(path) else { + return false; + }; + if !entry.owners.contains(&(pid, sock_idx)) { + entry.owners.push((pid, sock_idx)); + } + true + } + + /// Drop one process-local owner. The name remains registered while any + /// inherited endpoint is live; otherwise it becomes reusable (which is + /// essential for Linux abstract-namespace sockets, which have no inode). + pub fn remove_owner(&mut self, path: &[u8], pid: u32, sock_idx: usize) -> bool { + let resolved_path = if self.entries.contains_key(path) { + Some(path.to_vec()) + } else { + // A bound pathname can be renamed while the socket remains open. + // SocketInfo intentionally retains the sockaddr supplied to + // bind(2), so locate the renamed registry entry by stable owner. + self.entries + .iter() + .find(|(_, entry)| entry.owners.contains(&(pid, sock_idx))) + .map(|(registered_path, _)| registered_path.clone()) + }; + let Some(resolved_path) = resolved_path else { + return false; + }; + let Some(entry) = self.entries.get_mut(&resolved_path) else { + return false; + }; + let old_len = entry.owners.len(); + entry + .owners + .retain(|owner| *owner != (pid, sock_idx)); + if entry.owners.len() == old_len { + return false; + } + if entry.owners.is_empty() { + // A pathname socket leaves its filesystem node behind after the + // last close; keep a metadata tombstone until unlink so stat still + // reports S_IFSOCK and bind still sees EADDRINUSE. Abstract names + // have no inode and disappear immediately. + if resolved_path.first().copied() == Some(0) { + self.entries.remove(&resolved_path); + } + } else if entry.pid == pid && entry.sock_idx == sock_idx { + (entry.pid, entry.sock_idx) = entry.owners[0]; + } + true + } + + /// Re-key filesystem-backed socket metadata after a successful VFS + /// rename. Replacing an existing destination removes that destination's + /// old name, exactly as the filesystem operation did. + pub fn rename_path(&mut self, oldpath: &[u8], newpath: &[u8]) -> bool { + let old_entry = self.entries.remove(oldpath); + let replaced = self.entries.remove(newpath).is_some(); + if let Some(entry) = old_entry { + self.entries.insert(newpath.to_vec(), entry); + true + } else { + replaced + } + } + /// Look up a Unix socket by path. pub fn lookup(&self, path: &[u8]) -> Option<&UnixSocketEntry> { - self.entries.get(path) + self.entries + .get(path) + .filter(|entry| !entry.owners.is_empty()) } /// Remove a Unix socket registration by path. @@ -53,7 +133,16 @@ impl UnixSocketRegistry { /// Remove all registrations for a given pid (process cleanup). pub fn cleanup_process(&mut self, pid: u32) { - self.entries.retain(|_, entry| entry.pid != pid); + self.entries.retain(|path, entry| { + entry.owners.retain(|owner| owner.0 != pid); + if entry.owners.is_empty() { + return path.first().copied() != Some(0); + } + if entry.pid == pid { + (entry.pid, entry.sock_idx) = entry.owners[0]; + } + true + }); } /// Check if a path is registered (for stat/lstat). @@ -107,18 +196,54 @@ mod tests { assert!(reg.lookup(b"/tmp/test.sock").is_none()); } + #[test] + fn rename_rekeys_path_and_owner_cleanup_finds_new_name() { + let mut reg = UnixSocketRegistry::new(); + assert!(reg.register(b"/tmp/old.sock".to_vec(), 1, 7)); + + assert!(reg.rename_path(b"/tmp/old.sock", b"/tmp/new.sock")); + assert!(reg.lookup(b"/tmp/old.sock").is_none()); + assert!(reg.lookup(b"/tmp/new.sock").is_some()); + + assert!(reg.remove_owner(b"/tmp/old.sock", 1, 7)); + assert!(reg.lookup(b"/tmp/new.sock").is_none()); + assert!(reg.contains(b"/tmp/new.sock")); + } + + #[test] + fn rename_overwrites_stale_destination_registration() { + let mut reg = UnixSocketRegistry::new(); + assert!(reg.register(b"/tmp/source.sock".to_vec(), 1, 1)); + assert!(reg.register(b"/tmp/destination.sock".to_vec(), 2, 2)); + + assert!(reg.rename_path(b"/tmp/source.sock", b"/tmp/destination.sock")); + let entry = reg.lookup(b"/tmp/destination.sock").unwrap(); + assert_eq!((entry.pid, entry.sock_idx), (1, 1)); + } + #[test] fn test_cleanup_process() { let mut reg = UnixSocketRegistry::new(); - reg.register(b"/tmp/a.sock".to_vec(), 1, 0); - reg.register(b"/tmp/b.sock".to_vec(), 1, 1); + reg.register(b"\0a".to_vec(), 1, 0); + reg.register(b"\0b".to_vec(), 1, 1); reg.register(b"/tmp/c.sock".to_vec(), 2, 0); reg.cleanup_process(1); - assert!(reg.lookup(b"/tmp/a.sock").is_none()); - assert!(reg.lookup(b"/tmp/b.sock").is_none()); + assert!(reg.lookup(b"\0a").is_none()); + assert!(reg.lookup(b"\0b").is_none()); assert!(reg.lookup(b"/tmp/c.sock").is_some()); } + #[test] + fn test_pathname_metadata_remains_until_unlink() { + let mut reg = UnixSocketRegistry::new(); + reg.register(b"/tmp/stale.sock".to_vec(), 1, 0); + assert!(reg.remove_owner(b"/tmp/stale.sock", 1, 0)); + assert!(reg.contains(b"/tmp/stale.sock")); + assert!(reg.lookup(b"/tmp/stale.sock").is_none()); + assert!(reg.unregister(b"/tmp/stale.sock")); + assert!(!reg.contains(b"/tmp/stale.sock")); + } + #[test] fn test_contains() { let mut reg = UnixSocketRegistry::new(); @@ -136,4 +261,16 @@ mod tests { let entry = reg.lookup(b"/tmp/test.sock").unwrap(); assert_eq!(entry.pid, 2); } + + #[test] + fn test_inherited_owner_keeps_registration_live() { + let mut reg = UnixSocketRegistry::new(); + reg.register(b"\0abstract".to_vec(), 10, 4); + assert!(reg.add_owner(b"\0abstract", 20, 4)); + assert!(reg.remove_owner(b"\0abstract", 10, 4)); + let entry = reg.lookup(b"\0abstract").unwrap(); + assert_eq!((entry.pid, entry.sock_idx), (20, 4)); + assert!(reg.remove_owner(b"\0abstract", 20, 4)); + assert!(reg.lookup(b"\0abstract").is_none()); + } } diff --git a/crates/kernel/src/wakeup.rs b/crates/kernel/src/wakeup.rs index b709e3a750..9ede12b359 100644 --- a/crates/kernel/src/wakeup.rs +++ b/crates/kernel/src/wakeup.rs @@ -1,8 +1,9 @@ //! Wakeup event buffer for kernel-driven poll/select notification. //! -//! When pipe operations or listener accept queues change readiness state, -//! events are pushed into a global buffer. The host drains this buffer after -//! each syscall to wake only the specific waiters that are affected. +//! When pipe operations, listener accept queues, or AF_UNIX datagram send +//! state changes readiness, events are pushed into a global buffer. The host +//! drains this buffer after each syscall and wakes targeted waiters where an +//! identity is available or performs a bounded broad retry otherwise. use alloc::vec::Vec; use core::cell::UnsafeCell; @@ -16,6 +17,14 @@ pub const WAKE_WRITABLE: u8 = 2; /// Listener accept queue received a pending connection. pub const WAKE_ACCEPT: u8 = 4; +/// AF_UNIX datagram send readiness or its immediate result changed. +/// +/// Datagram writers do not have a pipe index that the host can target. The +/// host therefore retries untargeted blocked sends and issues a broad +/// readiness wake for poll/select/epoll operations when capacity, +/// associations, shutdown, close, or pathname state changes. +pub const WAKE_DATAGRAM_WRITABLE: u8 = 8; + /// A readiness change event. #[derive(Debug, Clone, Copy)] pub struct WakeupEvent { @@ -24,6 +33,7 @@ pub struct WakeupEvent { } struct WakeupBuffer { + #[cfg(not(test))] events: UnsafeCell>, next_accept_idx: UnsafeCell, } @@ -31,10 +41,19 @@ struct WakeupBuffer { unsafe impl Sync for WakeupBuffer {} static WAKEUP_BUFFER: WakeupBuffer = WakeupBuffer { + #[cfg(not(test))] events: UnsafeCell::new(Vec::new()), next_accept_idx: UnsafeCell::new(1), }; +// Kernel Wasm execution is serialized, but native unit tests run in parallel. +// Isolate their event queues so one test cannot drain another test's wakeups. +#[cfg(test)] +std::thread_local! { + static TEST_WAKEUP_EVENTS: core::cell::RefCell> = + core::cell::RefCell::new(Vec::new()); +} + /// Allocate a host-visible readiness token for a listening socket. pub fn alloc_accept_wake_idx() -> u32 { let next = unsafe { &mut *WAKEUP_BUFFER.next_accept_idx.get() }; @@ -48,7 +67,17 @@ pub fn alloc_accept_wake_idx() -> u32 { /// Push a wakeup event into the global buffer. pub fn push(idx: u32, wake_type: u8) { + #[cfg(test)] + { + TEST_WAKEUP_EVENTS.with(|events| { + events.borrow_mut().push(WakeupEvent { idx, wake_type }); + }); + return; + } + + #[cfg(not(test))] let events = unsafe { &mut *WAKEUP_BUFFER.events.get() }; + #[cfg(not(test))] events.push(WakeupEvent { idx, wake_type }); } @@ -57,12 +86,31 @@ pub fn push_accept(accept_idx: u32) { push(accept_idx, WAKE_ACCEPT); } +/// Notify the host that AF_UNIX datagram send readiness or its immediate +/// outcome may have changed. +pub fn push_datagram_writable() { + push(0, WAKE_DATAGRAM_WRITABLE); +} + /// Drain all pending wakeup events, writing them to the output buffer. /// Returns the number of events written. /// /// Each event is serialized as: idx (u32 LE) + wake_type (u8) = 5 bytes. pub fn drain(out: &mut [u8], max_events: u32) -> u32 { + #[cfg(test)] + { + return TEST_WAKEUP_EVENTS.with(|events| { + drain_events(&mut events.borrow_mut(), out, max_events) + }); + } + + #[cfg(not(test))] let events = unsafe { &mut *WAKEUP_BUFFER.events.get() }; + #[cfg(not(test))] + return drain_events(events, out, max_events); +} + +fn drain_events(events: &mut Vec, out: &mut [u8], max_events: u32) -> u32 { let count = events.len().min(max_events as usize); let bytes_per_event = 5; let max_by_buf = out.len() / bytes_per_event; @@ -79,7 +127,10 @@ pub fn drain(out: &mut [u8], max_events: u32) -> u32 { out[offset + 4] = ev.wake_type; } - events.clear(); + // Preserve events that did not fit this host drain. Lifecycle wakeups + // share this channel with readiness events, so dropping overflow could + // strand a stopped or resumed process indefinitely. + events.drain(..count); count as u32 } @@ -88,8 +139,7 @@ mod tests { use super::*; fn reset() { - let events = unsafe { &mut *WAKEUP_BUFFER.events.get() }; - events.clear(); + TEST_WAKEUP_EVENTS.with(|events| events.borrow_mut().clear()); let next = unsafe { &mut *WAKEUP_BUFFER.next_accept_idx.get() }; *next = 1; } @@ -101,10 +151,11 @@ mod tests { push(5, WAKE_READABLE); push(10, WAKE_WRITABLE); push_accept(12); + push_datagram_writable(); - let mut buf = [0u8; 20]; + let mut buf = [0u8; 25]; let count = drain(&mut buf, 10); - assert_eq!(count, 3); + assert_eq!(count, 4); // Event 0: pipe_idx=5, WAKE_READABLE assert_eq!(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]), 5); @@ -118,6 +169,10 @@ mod tests { assert_eq!(u32::from_le_bytes([buf[10], buf[11], buf[12], buf[13]]), 12); assert_eq!(buf[14], WAKE_ACCEPT); + // Event 3: broad datagram-writable readiness change. + assert_eq!(u32::from_le_bytes([buf[15], buf[16], buf[17], buf[18]]), 0); + assert_eq!(buf[19], WAKE_DATAGRAM_WRITABLE); + // Buffer should be empty now let count2 = drain(&mut buf, 10); assert_eq!(count2, 0); @@ -148,8 +203,37 @@ mod tests { let count = drain(&mut buf, 2); assert_eq!(count, 2); - // drain clears all, even if not all were written + assert_eq!(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]), 1); + assert_eq!(buf[4], WAKE_READABLE); + assert_eq!(u32::from_le_bytes([buf[5], buf[6], buf[7], buf[8]]), 2); + assert_eq!(buf[9], WAKE_WRITABLE); + + // The event that did not fit remains queued for the next drain. + buf.fill(0); let count2 = drain(&mut buf, 10); - assert_eq!(count2, 0); + assert_eq!(count2, 1); + assert_eq!(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]), 3); + assert_eq!(buf[4], WAKE_READABLE); + } + + #[test] + fn lifecycle_transitions_emit_exactly_one_wakeup_each() { + use crate::process::Process; + use wasm_posix_shared::signal::SIGTSTP; + use wasm_posix_shared::wait::{WAKE_PROCESS_CONTINUED, WAKE_PROCESS_STOPPED}; + + reset(); + let mut proc = Process::new(77); + assert!(proc.record_stop(SIGTSTP)); + assert!(!proc.record_stop(SIGTSTP)); + assert!(proc.record_continue()); + assert!(!proc.record_continue()); + + let mut buf = [0u8; 10]; + assert_eq!(drain(&mut buf, 2), 2); + assert_eq!(u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]), 77); + assert_eq!(buf[4], WAKE_PROCESS_STOPPED as u8); + assert_eq!(u32::from_le_bytes([buf[5], buf[6], buf[7], buf[8]]), 77); + assert_eq!(buf[9], WAKE_PROCESS_CONTINUED as u8); } } diff --git a/crates/kernel/src/wasm_api.rs b/crates/kernel/src/wasm_api.rs index 93f9c8ad94..c2f5ddfe82 100644 --- a/crates/kernel/src/wasm_api.rs +++ b/crates/kernel/src/wasm_api.rs @@ -17,11 +17,18 @@ extern crate alloc; use alloc::vec::Vec; use core::slice; -use wasm_posix_shared::fd_flags::FD_CLOEXEC; -use wasm_posix_shared::{Errno, WasmDirent, WasmStat, WasmStatfs, WasmTimespec}; +use wasm_posix_shared::{ + Errno, KernelWaitResult, WasmDirent, WasmStat, WasmStatfs, WasmTimespec, +}; use crate::ofd::FileType; -use crate::process::{HostIO, Process, StdioConfig, StdioKind}; +use crate::process::{ + HostIO, Process, ProcessState, StdioConfig, StdioKind, normalize_posix_timer_signo, +}; +use crate::signal::{ + DefaultSignalOutcome, apply_default_signal_action, deliver_pending_signals, + dequeue_signal_for, terminate_process_by_signal, +}; use crate::syscalls; // --------------------------------------------------------------------------- @@ -40,6 +47,8 @@ unsafe extern "C" { fn host_stat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; fn host_lstat(path_ptr: *const u8, path_len: u32, stat_ptr: *mut u8) -> i32; fn host_statfs(path_ptr: *const u8, path_len: u32, statfs_ptr: *mut u8) -> i32; + fn host_pathconf(path_ptr: *const u8, path_len: u32, name: i32, value_ptr: *mut i64) -> i32; + fn host_fpathconf(handle: i64, name: i32, value_ptr: *mut i64) -> i32; fn host_mkdir(path_ptr: *const u8, path_len: u32, mode: u32) -> i32; fn host_rmdir(path_ptr: *const u8, path_len: u32) -> i32; fn host_unlink(path_ptr: *const u8, path_len: u32) -> i32; @@ -54,6 +63,7 @@ unsafe extern "C" { fn host_readlink(path_ptr: *const u8, path_len: u32, buf_ptr: *mut u8, buf_len: u32) -> i32; fn host_chmod(path_ptr: *const u8, path_len: u32, mode: u32) -> i32; fn host_chown(path_ptr: *const u8, path_len: u32, uid: u32, gid: u32) -> i32; + fn host_lchown(path_ptr: *const u8, path_len: u32, uid: u32, gid: u32) -> i32; fn host_access(path_ptr: *const u8, path_len: u32, amode: u32) -> i32; fn host_opendir(path_ptr: *const u8, path_len: u32) -> i64; fn host_readdir(dir_handle: i64, dirent_ptr: *mut u8, name_ptr: *mut u8, name_len: u32) -> i32; @@ -372,6 +382,27 @@ impl HostIO for WasmHostIO { Ok(statfs) } + fn host_pathconf(&mut self, path: &[u8], name: i32) -> Result, Errno> { + let mut value = -1i64; + let result = unsafe { + host_pathconf( + path.as_ptr(), + path.len() as u32, + name, + &mut value as *mut i64, + ) + }; + i32_to_result(result)?; + Ok((value != -1).then_some(value)) + } + + fn host_fpathconf(&mut self, handle: i64, name: i32) -> Result, Errno> { + let mut value = -1i64; + let result = unsafe { host_fpathconf(handle, name, &mut value as *mut i64) }; + i32_to_result(result)?; + Ok((value != -1).then_some(value)) + } + fn host_mkdir(&mut self, path: &[u8], mode: u32) -> Result<(), Errno> { let result = unsafe { host_mkdir(path.as_ptr(), path.len() as u32, mode) }; i32_to_result(result) @@ -452,6 +483,11 @@ impl HostIO for WasmHostIO { i32_to_result(result) } + fn host_lchown(&mut self, path: &[u8], uid: u32, gid: u32) -> Result<(), Errno> { + let result = unsafe { host_lchown(path.as_ptr(), path.len() as u32, uid, gid) }; + i32_to_result(result) + } + fn host_access(&mut self, path: &[u8], amode: u32) -> Result<(), Errno> { let result = unsafe { host_access(path.as_ptr(), path.len() as u32, amode) }; i32_to_result(result) @@ -1058,7 +1094,7 @@ use crate::process_table::GLOBAL_PROCESS_TABLE as PROCESS_TABLE; /// Get all active PIDs from the process table. pub(crate) fn procfs_all_pids() -> Vec { let table = unsafe { &*PROCESS_TABLE.0.get() }; - table.all_pids() + table.procfs_pids() } /// Generate procfs content for a foreign process (cross-process access). @@ -1101,7 +1137,7 @@ pub(crate) fn procfs_getdents64_for_pid( ) -> Option<(usize, i64, bool)> { let table = unsafe { &*PROCESS_TABLE.0.get() }; let proc = table.get(pid)?; - let pids = table.all_pids(); + let pids = table.procfs_pids(); crate::procfs::procfs_getdents64(proc, ofd_path, buf, offset, &pids).ok() } @@ -1191,45 +1227,6 @@ fn ensure_memory_covers(_end_addr: usize) { // 3c. Signal delivery at syscall boundaries // --------------------------------------------------------------------------- -/// Check for and deliver pending signals before/after syscall. -fn deliver_pending_signals(proc: &mut Process, host: &mut WasmHostIO) { - use crate::signal::{DefaultAction, SignalHandler, default_action}; - let tid = crate::process_table::current_tid(); - let _ = host; - loop { - // Caught signals are delivered by the glue code via - // kernel_dequeue_signal; default and ignored signals are consumed here. - let deliverable = proc.deliverable_for(tid); - if deliverable == 0 { - break; - } - let signum = deliverable.trailing_zeros() + 1; - if signum >= wasm_posix_shared::signal::NSIG { - break; - } - let action = proc.signals.get_action(signum); - match action.handler { - SignalHandler::Handler(_) => break, - SignalHandler::Default => { - let _ = dequeue_signal_for(proc, tid, signum); - match default_action(signum) { - DefaultAction::Terminate | DefaultAction::CoreDump => { - proc.state = crate::process::ProcessState::Exited; - proc.exit_status = 128 + signum as i32; - } - _ => {} - } - } - SignalHandler::Ignore => { - let _ = dequeue_signal_for(proc, tid, signum); - } - } - if proc.state == crate::process::ProcessState::Exited { - break; - } - } -} - // --------------------------------------------------------------------------- // 4. Exported kernel functions // --------------------------------------------------------------------------- @@ -1433,14 +1430,17 @@ pub extern "C" fn kernel_reserve_host_region_at(pid: u32, addr: usize, len: usiz /// Set the working directory for a process. /// Called by host to set the initial cwd before the process starts. -/// Returns 0 on success, -ESRCH if pid not found. +/// Returns 0 on success or a negative errno if the process/path is invalid. #[unsafe(no_mangle)] pub extern "C" fn kernel_set_cwd(pid: u32, path_ptr: *const u8, path_len: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; if let Some(proc) = table.get_mut(pid) { let path = unsafe { core::slice::from_raw_parts(path_ptr, path_len as usize) }; - proc.cwd = path.to_vec(); - 0 + let mut host = WasmHostIO; + match syscalls::sys_chdir(proc, &mut host, path) { + Ok(()) => 0, + Err(error) => -(error as i32), + } } else { -(Errno::ESRCH as i32) } @@ -1489,10 +1489,61 @@ pub extern "C" fn kernel_set_process_argv(pid: u32, data_ptr: *const u8, data_le } } +/// Clear one process string vector before bounded, entry-at-a-time replacement. +/// +/// `kind == 0` selects argv and `kind == 1` selects the environment. The host +/// uses this together with `kernel_push_process_metadata_entry` instead of +/// copying an arbitrarily large NUL-joined payload into its fixed-size scratch +/// allocation. Clearing without any subsequent pushes deliberately represents +/// an empty vector, which is required when exec installs an empty environment. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_clear_process_metadata(pid: u32, kind: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + match proc.clear_metadata(kind) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +/// Append one argv or environment entry from the host's bounded scratch area. +/// Empty entries are preserved; entry boundaries are supplied by the call +/// itself rather than inferred from NUL bytes. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_push_process_metadata_entry( + pid: u32, + kind: u32, + data_ptr: *const u8, + data_len: u32, +) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let Some(proc) = table.get_mut(pid) else { + return -(Errno::ESRCH as i32); + }; + let data = unsafe { core::slice::from_raw_parts(data_ptr, data_len as usize) }; + match proc.push_metadata_entry(kind, data) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + fn finish_removed_process(pid: u32, result: crate::process_table::RemoveProcessResult) { use core::sync::atomic::Ordering; let removed = result.process; + // A process removed without reaching sys_exit (worker crash or explicit + // host termination) can still own host-side VFS handles. Close directory + // iterators before their backing file handles, + // matching sys_close/process-exit ordering. Normal exited zombies already + // have empty OFD and directory-stream tables, so reaping is a no-op here. + for dir_handle in result.host_dir_closes { + unsafe { host_closedir(dir_handle) }; + } + for handle in result.host_closes { + unsafe { host_close(handle) }; + } // /dev/fb0 cleanup: if the exiting process held a live mmap, tell the host // to drop the canvas binding before the process Memory disappears. Then // release the global owner claim — best-effort CAS makes this idempotent. @@ -1660,13 +1711,35 @@ pub extern "C" fn kernel_clear_fork_child(pid: u32) -> i32 { } } -/// Get process exit status. -/// Returns exit_status if process is exited, -1 if still alive, -ESRCH if not found. +/// Get the shell-style process exit status used by the host lifecycle scan. +/// Returns a normal exit code, 128+signal for signal termination, -1 while +/// alive, or -ESRCH when the process does not exist. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_process_exit_status(pid: u32) -> i32 { let table = unsafe { &*PROCESS_TABLE.0.get() }; match table.get(pid) { - Some(proc) if proc.state == crate::process::ProcessState::Exited => proc.exit_status, + Some(proc) if proc.state == crate::process::ProcessState::Exited => { + if proc.exit_signal != 0 { + 128 + proc.exit_signal as i32 + } else { + proc.exit_status + } + } + Some(_) => -1, + None => -(Errno::ESRCH as i32), + } +} + +/// Return the signal that terminated an exited process, or zero for a normal +/// exit. Returns -1 while the process is alive and -ESRCH when it is absent. +/// Hosts use this explicit cause instead of guessing from high exit codes. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_get_process_exit_signal(pid: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) if proc.state == crate::process::ProcessState::Exited => { + proc.exit_signal as i32 + } Some(_) => -1, None => -(Errno::ESRCH as i32), } @@ -1682,6 +1755,24 @@ pub extern "C" fn kernel_get_parent_pid(pid: u32) -> i32 { } } +/// Return the host-visible lifecycle state. Reaped limbo group identities are +/// not processes and report ESRCH just like an absent pid. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_get_process_state(pid: u32) -> i32 { + use crate::process::ProcessState; + use wasm_posix_shared::wait::{ + PROCESS_STATE_EXITED, PROCESS_STATE_RUNNING, PROCESS_STATE_STOPPED, + }; + + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid).map(|proc| proc.state) { + Some(ProcessState::Running) => PROCESS_STATE_RUNNING, + Some(ProcessState::Stopped) => PROCESS_STATE_STOPPED, + Some(ProcessState::Exited) => PROCESS_STATE_EXITED, + Some(ProcessState::Limbo) | None => -(Errno::ESRCH as i32), + } +} + /// Mark a process as signal-terminated without removing it from the table. /// /// Used by the host when the Worker dies before the guest reaches SYS_EXIT. @@ -1689,25 +1780,55 @@ pub extern "C" fn kernel_get_parent_pid(pid: u32) -> i32 { #[unsafe(no_mangle)] pub extern "C" fn kernel_mark_process_signaled(pid: u32, signum: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - match table.mark_process_signaled(pid, signum) { - Ok(()) => 0, - Err(e) => -(e as i32), + match table.get_mut(pid) { + Some(proc) => { + let mut host = WasmHostIO; + terminate_process_by_signal(proc, &mut host, signum); + 0 + } + None => -(Errno::ESRCH as i32), } } -/// Poll for a waitable child matching waitpid-style `target_pid`. -/// -/// Returns a child pid and writes its wait status to `status_ptr` when a -/// zombie matches, 0 when a matching child is still running, or negative -/// errno when no matching child exists. +/// Atomically select and optionally consume one child status record. A +/// consuming exit selection also reaps the child in this serialized kernel +/// operation; WNOWAIT peeks without consuming or reaping. #[unsafe(no_mangle)] -pub extern "C" fn kernel_wait4_poll(parent_pid: u32, target_pid: i32, status_ptr: *mut i32) -> i32 { - let table = unsafe { &*PROCESS_TABLE.0.get() }; - match table.poll_waitable_child(parent_pid, target_pid) { - Ok(Some((child_pid, wait_status))) => { - if !status_ptr.is_null() { - unsafe { - *status_ptr = wait_status; +pub extern "C" fn kernel_wait_child_poll( + parent_pid: u32, + target_pid: i32, + event_mask: u32, + flags: u32, + out_ptr: *mut KernelWaitResult, +) -> i32 { + if out_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + + let selected = { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + table.poll_wait_event(parent_pid, target_pid, event_mask, flags) + }; + + match selected { + Ok(Some((child_pid, event))) => { + let result = KernelWaitResult { + wait_status: event.wait_status, + si_code: event.si_code, + si_status: event.si_status, + child_uid: event.child_uid, + rusage: event.rusage, + }; + unsafe { + core::ptr::write_unaligned(out_ptr, result); + } + + if flags & wasm_posix_shared::wait::WNOWAIT == 0 + && event.event_mask == wasm_posix_shared::wait::EVENT_EXITED + { + let reaped = reap_process_and_cleanup(child_pid); + if reaped < 0 { + return reaped; } } child_pid as i32 @@ -1752,6 +1873,20 @@ pub extern "C" fn kernel_has_sa_nocldwait(pid: u32) -> i32 { } } +/// Check whether SIGCHLD stop/continue notifications are suppressed. +/// SA_NOCLDSTOP never suppresses the wait status record itself. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_has_sa_nocldstop(pid: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => { + let action = proc.signals.get_action(wasm_posix_shared::signal::SIGCHLD); + i32::from(action.flags & wasm_posix_shared::signal::SA_NOCLDSTOP != 0) + } + None => -(Errno::ESRCH as i32), + } +} + /// Reset signal mask for a process. /// Fork children re-execute _start, so they don't get musl's __restore_sigs /// after fork(). Clear the blocked mask so the child starts with no signals @@ -1900,15 +2035,37 @@ pub extern "C" fn kernel_get_fd_path(pid: u32, fd: i32, buf_ptr: *mut u8, buf_le } } +/// Return 1 when `fd` names a live descriptor in `pid`, 0 when it does not, +/// and a negative errno when the process itself is absent. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_fd_is_open(pid: u32, fd: i32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => i32::from(proc.fd_table.get(fd).is_ok()), + None => -(Errno::ESRCH as i32), + } +} + +/// Return 1 when `fd` can back host-persisted MAP_SHARED writeback, 0 for an +/// unsupported or absent descriptor, and `-ESRCH` when `pid` is absent. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_fd_supports_mmap_writeback(pid: u32, fd: i32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => i32::from(syscalls::fd_supports_mmap_writeback(proc, fd)), + None => -(Errno::ESRCH as i32), + } +} + /// Snapshot the process table for the host (Kandelo Inspector → Procs tab, /// and any host that wants a `ps`-equivalent view without spawning a user /// process). Walks every active pid and writes a compact, length-prefixed /// binary record per process into the host-supplied scratch buffer. /// /// Zombie (Exited) processes are omitted by default — the kandelo Inspector -/// wants a "currently running" view, and the kernel keeps zombies around for -/// waitpid() reap semantics that aren't visible at this layer. The internal -/// procfs ABI still surfaces them via /proc/[pid] for processes that care. +/// wants a live-process view, including stopped processes, and the kernel +/// keeps zombies around for waitpid() reap semantics. Procfs still surfaces +/// them via /proc/[pid]. /// /// Wire format (all integers little-endian): /// @@ -1919,7 +2076,7 @@ pub extern "C" fn kernel_get_fd_path(pid: u32, fd: i32, buf_ptr: *mut u8, buf_le /// u32 uid -- effective uid for ps-style USER display /// u32 gid -- effective gid /// u64 vsize_bytes -- sum of mmap-region sizes -/// u32 state -- 'R' (running) or 'Z' (zombie) as ASCII +/// u32 state -- 'R' (running) or 'T' (stopped) as ASCII /// u32 comm_len /// u32 cmdline_len /// [comm_len bytes] -- process_name(proc) — basename of argv[0] @@ -1942,7 +2099,10 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { Some(p) => p, None => continue, }; - if proc.state != crate::process::ProcessState::Running { + if matches!( + proc.state, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo + ) { continue; } let cmdline = crate::procfs::generate_cmdline(proc); @@ -1965,15 +2125,23 @@ pub extern "C" fn kernel_enum_procs(out_ptr: *mut u8, out_len: u32) -> i32 { Some(p) => p, None => continue, }; - // Drop zombies: the kernel keeps Exited entries for waitpid() - // reap semantics, but a "currently running" view shouldn't show - // them. - if proc.state != crate::process::ProcessState::Running { + // Drop zombies and reaped limbo identities, but retain stopped + // processes in the live-process view. + if matches!( + proc.state, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo + ) { continue; } let cmdline = crate::procfs::generate_cmdline(proc); let comm = process_name_bytes(proc); - let state: u32 = b'R' as u32; + let state: u32 = match proc.state { + crate::process::ProcessState::Running => b'R' as u32, + crate::process::ProcessState::Stopped => b'T' as u32, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo => { + unreachable!("non-live processes were filtered above") + } + }; let vsize: u64 = proc.memory.mappings().iter().map(|r| r.len as u64).sum(); write_u32(buf, &mut off, proc.pid); @@ -2066,18 +2234,14 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { // shared-pending bits (Process.signals.pending), but we collapse to a // single bitmask here — the actual dequeue routine below picks the // right queue. - let deliverable = proc.deliverable_for(tid); - if deliverable == 0 { + let Some(signum) = proc.next_deliverable_signal(tid) else { return 0; - } - let signum = deliverable.trailing_zeros() + 1; - if signum >= wasm_posix_shared::signal::NSIG { - return 0; - } + }; let action = proc.signals.get_action(signum); match action.handler { SignalHandler::Handler(idx) => { - let (_sig, si_value, si_code) = dequeue_signal_for(proc, tid, signum); + let (_sig, si_value, si_code, siginfo_word_1, siginfo_word_2) = + dequeue_signal_for(proc, tid, signum); // If returning from sigsuspend/ppoll/pselect, restore original // mask *before* saving old_mask for the handler, so the // handler's saved mask is the pre-sigsuspend mask. @@ -2109,7 +2273,8 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { // Write to output buffer: // [0..4] signum, [4..8] handler_idx, [8..12] flags, // [12..16] si_value, [16..24] old_mask, - // [24..28] si_code, [28..32] si_pid, [32..36] si_uid, + // [24..28] si_code, [28..32] first siginfo union word, + // [32..36] second siginfo union word, // [36..40] alt_sp (0 if no switch), [40..44] alt_size let buf = unsafe { slice::from_raw_parts_mut(out_ptr, 44) }; buf[0..4].copy_from_slice(&signum.to_le_bytes()); @@ -2118,8 +2283,8 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { buf[12..16].copy_from_slice(&si_value.to_le_bytes()); buf[16..24].copy_from_slice(&old_mask.to_le_bytes()); buf[24..28].copy_from_slice(&si_code.to_le_bytes()); - buf[28..32].copy_from_slice(&proc.pid.to_le_bytes()); - buf[32..36].copy_from_slice(&proc.uid.to_le_bytes()); + buf[28..32].copy_from_slice(&siginfo_word_1.to_le_bytes()); + buf[32..36].copy_from_slice(&siginfo_word_2.to_le_bytes()); if switch_to_alt_stack { buf[36..40].copy_from_slice(&(proc.alt_stack_sp as u32).to_le_bytes()); buf[40..44].copy_from_slice(&(proc.alt_stack_size as u32).to_le_bytes()); @@ -2129,20 +2294,11 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { return signum as i32; } SignalHandler::Default => { - use crate::signal::{DefaultAction, default_action}; let _ = dequeue_signal_for(proc, tid, signum); - match default_action(signum) { - DefaultAction::Terminate | DefaultAction::CoreDump => { - // Process is dying; clear sigsuspend state - proc.sigsuspend_saved_mask = None; - for t in proc.threads.iter_mut() { - t.signals.sigsuspend_saved_mask = None; - } - proc.state = crate::process::ProcessState::Exited; - proc.exit_status = 128 + signum as i32; - return 0; - } - _ => continue, + let mut host = WasmHostIO; + match apply_default_signal_action(proc, &mut host, signum) { + DefaultSignalOutcome::Continue => continue, + DefaultSignalOutcome::Stopped | DefaultSignalOutcome::Exited => return 0, } } SignalHandler::Ignore => { @@ -2153,159 +2309,111 @@ pub extern "C" fn kernel_dequeue_signal(pid: u32, out_ptr: *mut u8) -> i32 { } } -/// Dequeue one pending instance of `signum` for the given thread. Prefers the -/// thread's directed pending queue over the shared process-level queue — -/// POSIX requires that signals sent via `pthread_kill` / `tkill` / `tgkill` -/// be delivered to that thread specifically, even when the shared queue also -/// carries an instance of the same signal. -/// -/// Returns `(signum, si_value, si_code)`; `si_value`/`si_code` default to 0 -/// for coalesced standard signals without `sigqueue` metadata. -fn dequeue_signal_for( - proc: &mut crate::process::Process, - tid: u32, - signum: u32, -) -> (u32, i32, i32) { - use crate::signal::sig_bit; - // Prefer per-thread directed delivery for non-main threads. - if !proc.is_main_thread(tid) { - if let Some(t) = proc.get_thread_mut(tid) { - if (t.signals.pending & sig_bit(signum)) != 0 { - // Pull the entry from the thread's rt_queue if present, - // then clear the pending bit exactly as `SignalState::dequeue` - // does (coalesced for standard signals; queued for RT). - let (mut si_value, mut si_code) = (0i32, 0i32); - if let Some(pos) = t.signals.rt_queue.iter().position(|e| e.signum == signum) { - si_value = t.signals.rt_queue[pos].si_value; - si_code = t.signals.rt_queue[pos].si_code; - t.signals.rt_queue.remove(pos); - } - if signum >= crate::signal::SIGRTMIN { - if !t.signals.rt_queue.iter().any(|e| e.signum == signum) { - t.signals.pending &= !sig_bit(signum); - } - } else { - t.signals.pending &= !sig_bit(signum); - } - return (signum, si_value, si_code); - } - } - } - // Fall back to the shared process-level queue. - let (si_value, si_code) = proc.signals.consume_one(signum); - (signum, si_value, si_code) -} - /// Handle exec semantics on a process in the process table. -/// Serializes the process as exec state (closes CLOEXEC, resets handlers), then -/// re-creates the process from that sanitized state. +/// Closes CLOEXEC descriptors and resets image-specific state in place so +/// surviving kernel objects retain their exact identity and queues. /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_exec_setup(pid: u32) -> i32 { + kernel_exec_setup_inner(pid, pid) +} + +/// Thread-aware exec setup. When a pthread invokes exec, its signal mask and +/// directed pending signals become the surviving process thread's state. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_setup_for_thread(pid: u32, caller_tid: u32) -> i32 { + kernel_exec_setup_inner(pid, caller_tid) +} + +/// Validate the exec caller and apply any deferred posix_spawn file actions. +/// +/// The host calls this before it starts the irreversible address-space +/// transition. Keeping these fallible operations separate means a bad caller +/// tid or failed file action cannot strand a process after its old image has +/// already been discarded. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_exec_prepare(pid: u32, caller_tid: u32) -> i32 { + match prepare_exec_state(pid, caller_tid) { + Ok(()) => 0, + Err(e) => -(e as i32), + } +} + +fn prepare_exec_state(pid: u32, caller_tid: u32) -> Result<(), Errno> { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let proc = table.get_mut(pid).ok_or(Errno::ESRCH)?; + + if matches!( + proc.state, + crate::process::ProcessState::Exited | crate::process::ProcessState::Limbo + ) { + return Err(Errno::ESRCH); + } + + if caller_tid != 0 + && caller_tid != pid + && !proc.threads.iter().any(|thread| thread.tid == caller_tid) { - let proc = match table.get_mut(pid) { - Some(p) => p, - None => return -(Errno::ESRCH as i32), - }; + return Err(Errno::ESRCH); + } - // Apply pending fork fd actions (from posix_spawn) before exec. - // These are dup2/close ops that rearrange fds (e.g., pipe write end → fd 1) - // and must take effect before CLOEXEC removal during exec serialization. - if !proc.fork_fd_actions.is_empty() { - let actions: alloc::vec::Vec<_> = proc.fork_fd_actions.drain(..).collect(); - let mut host = WasmHostIO; - for action in actions { - use crate::process::FdAction; - match action { - FdAction::Dup2 { old_fd, new_fd } => { - if let Err(e) = syscalls::sys_dup2(proc, &mut host, old_fd, new_fd) { - return -(e as i32); - } - } - FdAction::Close { fd } => { - if let Err(e) = syscalls::sys_close(proc, &mut host, fd) { - return -(e as i32); - } - } - FdAction::Open { - fd, - ref path, - flags, - mode, - } => { - match syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32) { - Ok(opened_fd) => { - if opened_fd != fd { - if let Err(e) = - syscalls::sys_dup2(proc, &mut host, opened_fd, fd) - { - return -(e as i32); - } - let _ = syscalls::sys_close(proc, &mut host, opened_fd); - } - } - Err(e) => return -(e as i32), - } - } + // Apply pending fork fd actions (from posix_spawn) before exec. + // These are dup2/close/open operations that rearrange descriptors (for + // example, a pipe write end onto fd 1) and must precede CLOEXEC removal. + let actions: alloc::vec::Vec<_> = proc.fork_fd_actions.drain(..).collect(); + let mut host = WasmHostIO; + for action in actions { + use crate::process::FdAction; + match action { + FdAction::Dup2 { old_fd, new_fd } => { + syscalls::sys_dup2(proc, &mut host, old_fd, new_fd)?; + } + FdAction::Close { fd } => { + syscalls::sys_close(proc, &mut host, fd)?; + } + FdAction::Open { + fd, + ref path, + flags, + mode, + } => { + let opened_fd = + syscalls::sys_open(proc, &mut host, path, flags as u32, mode as u32)?; + if opened_fd != fd { + syscalls::sys_dup2(proc, &mut host, opened_fd, fd)?; + let _ = syscalls::sys_close(proc, &mut host, opened_fd); } } } } + Ok(()) +} - // Close CLOEXEC fds BEFORE serialization so pipe/host-handle refcounts - // are properly decremented. Without this, the exec serialization silently - // drops CLOEXEC fds from the FD table without adjusting global refcounts, - // leaving pipes with phantom writers and preventing EOF on reads. - { - let proc = match table.get_mut(pid) { - Some(p) => p, +fn kernel_exec_setup_inner(pid: u32, caller_tid: u32) -> i32 { + // Compatibility fallback for hosts that have not adopted the explicit + // prepare step yet. New hosts call kernel_exec_prepare first, leaving no + // actions here; validating twice is deliberate and harmless. + let has_pending_actions = { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + match table.get(pid) { + Some(proc) => !proc.fork_fd_actions.is_empty(), None => return -(Errno::ESRCH as i32), - }; - let cloexec_fds: alloc::vec::Vec = proc - .fd_table - .iter() - .filter(|(_, entry)| entry.fd_flags & FD_CLOEXEC != 0) - .map(|(fd, _)| fd) - .collect(); - let mut host = WasmHostIO; - for fd in cloexec_fds { - let _ = syscalls::sys_close(proc, &mut host, fd); + } + }; + if has_pending_actions { + if let Err(e) = prepare_exec_state(pid, caller_tid) { + return -(e as i32); } } - { - let proc = match table.get_mut(pid) { - Some(p) => p, - None => return -(Errno::ESRCH as i32), - }; - let mut host = WasmHostIO; - syscalls::release_exec_image_state(proc, &mut host); - } - - // Re-borrow after fd action scope ends - let proc = match table.get(pid) { - Some(p) => p, + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let proc = match table.get_mut(pid) { + Some(proc) => proc, None => return -(Errno::ESRCH as i32), }; - - // Serialize as exec state (signal handler reset, etc.) - // CLOEXEC fds were already closed above, so serialization just preserves what's left. - let mut buf = alloc::vec![0u8; 64 * 1024]; - let written = match crate::fork::serialize_exec_state(proc, &mut buf) { - Ok(n) => n, - Err(e) => return -(e as i32), - }; - - // Deserialize back to replace the process with exec-sanitized version - match crate::fork::deserialize_exec_state(&buf[..written], pid) { - Ok(new_proc) => { - table.get_mut(pid).map(|p| { - *p = new_proc; - p.has_exec = true; - }); - 0 - } + let mut host = WasmHostIO; + match syscalls::commit_exec_state(proc, &mut host, caller_tid) { + Ok(()) => 0, Err(e) => -(e as i32), } } @@ -2737,7 +2845,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { if a1 != 0 { let ptr = a1 as usize as *mut u8; unsafe { - let bytes = proc.signals.pending.to_le_bytes(); + let bytes = proc + .pending_for(crate::process_table::current_tid()) + .to_le_bytes(); for i in 0..8 { *ptr.add(i) = bytes[i]; } @@ -2782,16 +2892,18 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { -1 // NULL timeout = wait indefinitely }; let result = match syscalls::sys_sigtimedwait(proc, &mut host, mask, timeout_ms) { - Ok((sig, si_value, si_code)) => { + Ok((sig, si_value, si_code, siginfo_word_1, siginfo_word_2)) => { // Write siginfo_t if pointer is non-null if a2 != 0 { let p = a2 as usize as *mut u8; - // siginfo_t layout: si_signo(0), si_errno(4), si_code(8), - // si_pid(12), si_uid(16), si_value(20) + // Fixed channel siginfo transport: si_signo(0), + // si_errno(4), si_code(8), first union words (12/16), + // and sival_int(20). The host expands these fields to + // musl's eight-byte-aligned wasm64 siginfo_t layout. let sig_bytes = (sig as i32).to_le_bytes(); let code_bytes = si_code.to_le_bytes(); - let pid_bytes = proc.pid.to_le_bytes(); - let uid_bytes = proc.uid.to_le_bytes(); + let word_1_bytes = siginfo_word_1.to_le_bytes(); + let word_2_bytes = siginfo_word_2.to_le_bytes(); let val_bytes = si_value.to_le_bytes(); unsafe { for i in 0..4 { @@ -2801,10 +2913,10 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { *p.add(8 + i) = code_bytes[i]; } for i in 0..4 { - *p.add(12 + i) = pid_bytes[i]; + *p.add(12 + i) = word_1_bytes[i]; } for i in 0..4 { - *p.add(16 + i) = uid_bytes[i]; + *p.add(16 + i) = word_2_bytes[i]; } for i in 0..4 { *p.add(20 + i) = val_bytes[i]; @@ -3153,7 +3265,11 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { 105 => kernel_setgid(a1 as u32), // SYS_SETGID 106 => kernel_seteuid(a1 as u32), // SYS_SETEUID 107 => kernel_setegid(a1 as u32), // SYS_SETEGID - 108 => kernel_getrusage(a1, a2 as *mut u8, 144), // SYS_GETRUSAGE (musl passes 2 args; time64 rusage = 18x8 = 144) + 108 => kernel_getrusage( + a1, + a2 as *mut u8, + wasm_posix_shared::WASM_RUSAGE_WIRE_SIZE, + ), // SYS_GETRUSAGE (musl passes 2 args) 131 => kernel_setresuid(a1 as u32, a2 as u32, a3 as u32), // SYS_SETRESUID 132 => kernel_getresuid(a1 as *mut u32, a2 as *mut u32, a3 as *mut u32), // SYS_GETRESUID 133 => kernel_setresgid(a1 as u32, a2 as u32, a3 as u32), // SYS_SETRESGID @@ -3213,9 +3329,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // SYS_PATHCONF let p = a1 as *const u8; let len = unsafe { cstr_len(p) }; - kernel_pathconf(p, len as u32, a2) as i32 + kernel_pathconf(p, len as u32, a2, a3 as *mut i64) } - 113 => kernel_fpathconf(a1, a2) as i32, // SYS_FPATHCONF + 113 => kernel_fpathconf(a1, a2, a3 as *mut i64), // SYS_FPATHCONF // setreuid/setregid — map to setresuid/setresgid with -1 for saved ID 215 => kernel_setresuid(a1 as u32, a2 as u32, 0xFFFFFFFF), // SYS_SETREUID @@ -3600,10 +3716,10 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { ) } 299 => { - // SYS_LCHOWN: (path, uid, gid) — treat like chown (no symlink distinction) + // SYS_LCHOWN: (path, uid, gid) let p = a1 as *const u8; let len = unsafe { cstr_len(p) }; - kernel_chown(p, len, a2 as u32, a3 as u32) + kernel_lchown(p, len, a2 as u32, a3 as u32) } 307 => 0, // SYS_FADVISE64: advisory, always succeed @@ -3657,7 +3773,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let bytes = unsafe { slice::from_raw_parts(off_out_ptr, 8) }; Some(i64::from_le_bytes(bytes.try_into().unwrap())) }; - match syscalls::sys_copy_file_range( + let result = match syscalls::sys_copy_file_range( proc, &mut host, a1, @@ -3685,7 +3801,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { n as i32 } Err(e) => -(e as i32), - } + }; + deliver_pending_signals(proc, &mut host); + result } 291 => { @@ -3706,7 +3824,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let bytes = unsafe { slice::from_raw_parts(off_out_ptr, 8) }; Some(i64::from_le_bytes(bytes.try_into().unwrap())) }; - match syscalls::sys_splice( + let result = match syscalls::sys_splice( proc, &mut host, a1, @@ -3732,7 +3850,9 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { n as i32 } Err(e) => -(e as i32), - } + }; + deliver_pending_signals(proc, &mut host); + result } 293 => 0, // SYS_READAHEAD: advisory, always succeed 297 => kernel_preadv(a1, a2 as *mut u8, a3, a4 as u32, a5), // SYS_PREADV2 (ignore flags in a6) @@ -3740,21 +3860,7 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { // -- Scheduling stubs (single-CPU Wasm) -- 237 => 0, // SYS_SCHED_SETAFFINITY: no-op (single CPU) - 238 => { - // SYS_SCHED_GETAFFINITY: return a 1-bit cpuset - let size = a2 as usize; - let mask_ptr = a3 as *mut u8; - if size == 0 || mask_ptr.is_null() { - -(Errno::EINVAL as i32) - } else { - let mask = unsafe { slice::from_raw_parts_mut(mask_ptr, size) }; - for b in mask.iter_mut() { - *b = 0; - } - mask[0] = 1; // CPU 0 available - 0 - } - } + 238 => kernel_sched_getaffinity(a1, args[1] as u32, a3 as *mut u8), // -- Memory/sync stubs -- 257 => 0, // SYS_MEMBARRIER: no-op (single-threaded per process in Wasm) @@ -3786,10 +3892,12 @@ fn dispatch_channel_syscall(nr: u32, args: &[i64; 6]) -> i32 { let mut host = WasmHostIO; let offset = args[2]; let len = args[3]; - match syscalls::sys_fallocate(proc, &mut host, a1, offset, len) { + let result = match syscalls::sys_fallocate(proc, &mut host, a1, offset, len) { Ok(()) => 0, Err(e) => -(e as i32), - } + }; + deliver_pending_signals(proc, &mut host); + result } } 323 => 0, // SYS_SYNC_FILE_RANGE: advisory, no-op @@ -4494,11 +4602,16 @@ pub extern "C" fn kernel_get_fork_state(buf_ptr: *mut u8, buf_len: u32) -> i32 { /// Initialize kernel from serialized fork state (child side). #[unsafe(no_mangle)] pub extern "C" fn kernel_init_from_fork(buf_ptr: *const u8, buf_len: u32, child_pid: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + if table.get(child_pid).is_some() { + return -(Errno::EEXIST as i32); + } let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; match crate::fork::deserialize_fork_state(buf, child_pid) { Ok(proc) => { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.insert(child_pid, proc); + if let Err(e) = table.insert_legacy_fork_process(proc) { + return -(e as i32); + } table.set_current_pid(child_pid); 0 } @@ -4523,11 +4636,13 @@ pub extern "C" fn kernel_get_exec_state(buf_ptr: *mut u8, buf_len: u32) -> i32 { /// Returns 0 on success, negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_init_from_exec(buf_ptr: *const u8, buf_len: u32, pid: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let buf = unsafe { core::slice::from_raw_parts(buf_ptr, buf_len as usize) }; match crate::fork::deserialize_exec_state(buf, pid) { Ok(proc) => { - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - table.processes.insert(pid, proc); + if let Err(e) = table.replace_legacy_exec_process(pid, proc) { + return -(e as i32); + } table.set_current_pid(pid); 0 } @@ -4670,9 +4785,46 @@ pub extern "C" fn kernel_write(fd: i32, buf_ptr: *const u8, buf_len: u32) -> i32 result } -/// Seek within a file. The 64-bit offset is passed as two 32-bit halves -/// because some Wasm host bindings lack native i64 support. -/// Returns the new offset (i64) or negative errno (i64). +/// Resolve one logical write's operation-wide byte budget before the host +/// decomposes it into scratch-buffer-sized kernel calls. +/// +/// `positioned != 0` selects the supplied offset (pwrite/pwritev); otherwise +/// the open-file-description cursor and O_APPEND state are authoritative. +/// The host binds the calling TID before entering this export so SIGXFSZ is +/// queued for the thread that issued the operation. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_prepare_write_operation( + pid: u32, + fd: i32, + offset: i64, + requested_len: u32, + positioned: u32, +) -> i64 { + let _gkl = GklGuard::acquire(); + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + table.set_current_pid(pid); + let proc = match table.get_mut(pid) { + Some(proc) => proc, + None => return -(Errno::ESRCH as i64), + }; + let mut host = WasmHostIO; + let result = match syscalls::write_operation_budget( + proc, + &mut host, + fd, + (positioned != 0).then_some(offset), + requested_len as usize, + ) { + Ok(len) => len as i64, + Err(e) => -(e as i64), + }; + deliver_pending_signals(proc, &mut host); + result +} + +/// Seek within a file. The 64-bit offset is passed as two 32-bit halves +/// because some Wasm host bindings lack native i64 support. +/// Returns the new offset (i64) or negative errno (i64). #[unsafe(no_mangle)] pub extern "C" fn kernel_lseek(fd: i32, offset_lo: u32, offset_hi: i32, whence: u32) -> i64 { let (_gkl, proc) = unsafe { get_process() }; @@ -5267,6 +5419,24 @@ pub extern "C" fn kernel_chown(path_ptr: *const u8, path_len: u32, uid: u32, gid result } +/// Change symlink ownership without following the final link. +fn kernel_lchown( + path_ptr: *const u8, + path_len: u32, + uid: u32, + gid: u32, +) -> i32 { + let (_gkl, proc) = unsafe { get_process() }; + let path = unsafe { slice::from_raw_parts(path_ptr, path_len as usize) }; + let mut host = WasmHostIO; + let result = match syscalls::sys_lchown(proc, &mut host, path, uid, gid) { + Ok(()) => 0, + Err(e) => -(e as i32), + }; + deliver_pending_signals(proc, &mut host); + result +} + /// Check file accessibility. Returns 0 on success, or negative errno on error. #[unsafe(no_mangle)] pub extern "C" fn kernel_access(path_ptr: *const u8, path_len: u32, amode: u32) -> i32 { @@ -5313,11 +5483,11 @@ pub extern "C" fn kernel_fchdir(fd: i32) -> i32 { pub extern "C" fn kernel_getcwd(buf_ptr: *mut u8, buf_len: u32) -> i32 { let (_gkl, proc) = unsafe { get_process() }; let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; - let result = match syscalls::sys_getcwd(proc, buf) { + let mut host = WasmHostIO; + let result = match syscalls::sys_getcwd(proc, &mut host, buf) { Ok(n) => n as i32, Err(e) => -(e as i32), }; - let mut host = WasmHostIO; deliver_pending_signals(proc, &mut host); result } @@ -5572,7 +5742,7 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> i32 { -(Errno::EPERM as i32) } else { if sig > 0 { - target.signals.raise_with_value(sig, si_value); + target.raise_signal_with_value(sig, si_value); deliver_pending_signals(target, &mut host); } 0 @@ -5611,7 +5781,7 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> i32 { } delivered = true; if sig > 0 { - target.signals.raise_with_value(sig, si_value); + target.raise_signal_with_value(sig, si_value); deliver_pending_signals(target, &mut host); } } @@ -5630,7 +5800,7 @@ fn kernel_kill_with_value(pid: i32, sig: u32, si_value: i32) -> i32 { // For local sigqueue, raise with value on the current process directly. if si_value != 0 && sig > 0 { - proc.signals.raise_with_value(sig, si_value); + proc.raise_signal_with_value(sig, si_value); deliver_pending_signals(proc, &mut host); return 0; } @@ -5709,6 +5879,63 @@ fn kernel_sched_validate_pid(pid: i32) -> i32 { } } +/// Resolve the Linux task selected by sched_getaffinity. +/// +/// Process leaders use their PID as their TID. Worker threads live in their +/// owning Process record, while pid 0 selects the exact calling thread. Limbo +/// records are already reaped and no longer name a task. +fn sched_affinity_target_process( + table: &crate::process_table::ProcessTable, + pid: i32, +) -> Result<&Process, Errno> { + if pid == 0 { + let caller = table.get(table.current_pid()).ok_or(Errno::ESRCH)?; + let current_tid = table.current_tid(); + if !matches!(caller.state, ProcessState::Running | ProcessState::Stopped) { + return Err(Errno::ESRCH); + } + if caller.is_main_thread(current_tid) || caller.get_thread(current_tid).is_some() { + return Ok(caller); + } + return Err(Errno::ESRCH); + } + + if pid < 0 { + return Err(Errno::ESRCH); + } + + table + .get_process_containing_task(pid as u32) + .ok_or(Errno::ESRCH) +} + +/// Linux sched_getaffinity compatibility for Kandelo's one-CPU kernel. +/// +/// Linux validates the unsigned byte length against one kernel-word mask, +/// requires kernel-word alignment, and returns the number of bytes copied by +/// the raw syscall. Musl converts that positive raw result to public success 0 +/// and zero-fills the caller's remaining cpu_set_t bytes. +fn kernel_sched_getaffinity(pid: i32, cpusetsize: u32, mask_ptr: *mut u8) -> i32 { + const MASK_SIZE: u32 = wasm_posix_shared::SCHED_AFFINITY_MASK_SIZE; + + if cpusetsize < MASK_SIZE || cpusetsize % MASK_SIZE != 0 { + return -(Errno::EINVAL as i32); + } + + let table = unsafe { &*PROCESS_TABLE.0.get() }; + if let Err(error) = sched_affinity_target_process(table, pid) { + return -(error as i32); + } + if mask_ptr.is_null() { + return -(Errno::EFAULT as i32); + } + + let mask = unsafe { slice::from_raw_parts_mut(mask_ptr, MASK_SIZE as usize) }; + mask.fill(0); + mask[0] = 1; + MASK_SIZE as i32 +} + /// sched_getparam — write scheduling parameters (sched_priority = 0) to param_ptr. /// struct sched_param starts with int sched_priority at offset 0. #[unsafe(no_mangle)] @@ -5735,7 +5962,7 @@ pub extern "C" fn kernel_deliver_signal(sig: u32) -> i32 { if sig == 0 || sig >= wasm_posix_shared::signal::NSIG { return -(Errno::EINVAL as i32); } - proc.signals.raise(sig); + proc.raise_signal(sig); let mut host = WasmHostIO; deliver_pending_signals(proc, &mut host); 0 @@ -5758,8 +5985,7 @@ pub extern "C" fn kernel_raise(sig: u32) -> i32 { /// process. POSIX requires that directed signals go to that thread's pending /// queue, not the process-wide shared queue. /// -/// - `tid == 0` or `tid == pid` targets the main thread (process-level -/// pending, same as `raise`). +/// - `tid == 0` or `tid == pid` targets the main thread's directed queue. /// - Other `tid` values look up the thread in `Process::threads` and raise /// on its own per-thread pending queue. /// - Unknown `tid` → `-ESRCH`. @@ -5799,14 +6025,13 @@ fn kernel_tkill_with_value(tid: u32, sig: u32, si_value: i32, si_code: i32) -> i return -(Errno::EINVAL as i32); } - // Main thread: route to process-level (shared) pending. This preserves - // existing behaviour for raise() on a single-threaded process. + // Main thread: use its directed queue rather than the process-shared set. if proc.is_main_thread(tid) { if sig > 0 { if si_code != 0 || si_value != 0 { - proc.signals.raise_with_value(sig, si_value); + proc.raise_for_thread_with_value(tid, sig, si_value); } else { - proc.signals.raise(sig); + proc.raise_for_thread(tid, sig); } } deliver_pending_signals(proc, &mut host); @@ -5822,30 +6047,22 @@ fn kernel_tkill_with_value(tid: u32, sig: u32, si_value: i32, si_code: i32) -> i // in-process signalling that uses `tkill(self_tid, sig)`; aligning with // the previous "tkill is raise" behaviour keeps those paths alive while // per-thread routing is still correct for genuinely-known TIDs. - match proc.get_thread_mut(tid) { - Some(t) => { - if sig > 0 { - if si_code != 0 || si_value != 0 { - t.signals.raise_with_value(sig, si_value); - } else { - t.signals.raise(sig); - } - } - deliver_pending_signals(proc, &mut host); - 0 - } - None => { - if sig > 0 { - if si_code != 0 || si_value != 0 { - proc.signals.raise_with_value(sig, si_value); - } else { - proc.signals.raise(sig); - } + if sig > 0 { + let directed = if si_code != 0 || si_value != 0 { + proc.raise_for_thread_with_value(tid, sig, si_value) + } else { + proc.raise_for_thread(tid, sig) + }; + if !directed { + if si_code != 0 || si_value != 0 { + proc.raise_signal_with_value(sig, si_value); + } else { + proc.raise_signal(sig); } - deliver_pending_signals(proc, &mut host); - 0 } } + deliver_pending_signals(proc, &mut host); + 0 } /// Set signal action. act_ptr/oldact_ptr point to structs: @@ -6037,7 +6254,8 @@ pub extern "C" fn kernel_utimensat( result } -/// Remap memory. Returns MAP_FAILED (-1 as u32) since Wasm doesn't support this. +/// Remap memory. Supports in-place resize and MREMAP_MAYMOVE; unsupported +/// Linux-specific flag combinations are rejected by sys_mremap with EINVAL. #[unsafe(no_mangle)] pub extern "C" fn kernel_mremap( old_addr: usize, @@ -6602,7 +6820,11 @@ pub extern "C" fn kernel_recvmsg(fd: i32, msg_ptr: *mut u8, flags: u32) -> i32 { let base = u32::from_le_bytes([iov[0], iov[1], iov[2], iov[3]]) as usize; let len = u32::from_le_bytes([iov[4], iov[5], iov[6], iov[7]]) as usize; - let buf = unsafe { slice::from_raw_parts_mut(base as *mut u8, len) }; + let buf = if len == 0 { + &mut [] + } else { + unsafe { slice::from_raw_parts_mut(base as *mut u8, len) } + }; // Use recvfrom if msg_name is provided (to fill source address) let result = if name_ptr != 0 && name_len > 0 { @@ -6955,7 +7177,8 @@ pub extern "C" fn kernel_exit(status: i32) -> ! { if unsafe { host_is_thread_worker() } != 0 { // Thread exit: don't destroy shared process state (FDs, pipes, etc.). // Just set exit status and return — the glue will trap via unreachable. - proc.exit_status = status; + proc.exit_status = status & 0xff; + proc.exit_signal = 0; // Drop GKL guard before trapping } else { let mut host = WasmHostIO; @@ -7047,6 +7270,13 @@ pub extern "C" fn kernel_listen(fd: i32, backlog: u32) -> i32 { result } +/// Return whether `flags` contains only the accept4 flags this kernel supports. +fn accept4_flags_are_valid(flags: u32) -> bool { + use wasm_posix_shared::socket::{SOCK_CLOEXEC, SOCK_NONBLOCK}; + + flags & !(SOCK_CLOEXEC | SOCK_NONBLOCK) == 0 +} + /// Accept a connection with flags. Returns new fd or negative errno. /// Flags: SOCK_CLOEXEC, SOCK_NONBLOCK (same values as socket()). #[unsafe(no_mangle)] @@ -7062,6 +7292,10 @@ pub extern "C" fn kernel_accept4( let (_gkl, proc) = unsafe { get_process() }; let mut host = WasmHostIO; + if !accept4_flags_are_valid(flags) { + deliver_pending_signals(proc, &mut host); + return -(Errno::EINVAL as i32); + } let result = match syscalls::sys_accept(proc, &mut host, fd) { Ok(new_fd) => { // Apply SOCK_CLOEXEC flag @@ -7104,8 +7338,7 @@ pub extern "C" fn kernel_accept4( addrlen_buf.copy_from_slice(&2u32.to_le_bytes()); } } - _ => { - // Existing AF_INET logic + crate::socket::SocketDomain::Inet => { let mut sa = [0u8; 16]; sa[0] = 2; // AF_INET let port_be = sock.peer_port.to_be_bytes(); @@ -7121,6 +7354,19 @@ pub extern "C" fn kernel_accept4( addr_buf.copy_from_slice(&sa[..n]); addrlen_buf.copy_from_slice(&16u32.to_le_bytes()); } + crate::socket::SocketDomain::Inet6 => { + let mut sa = [0u8; 28]; + sa[0] = 10; // AF_INET6 + let port_be = sock.peer_port.to_be_bytes(); + sa[2] = port_be[0]; + sa[3] = port_be[1]; + sa[8..24].copy_from_slice(&sock.peer_addr6); + let n = max_len.min(28); + let addr_buf = + unsafe { slice::from_raw_parts_mut(addr_ptr, n) }; + addr_buf.copy_from_slice(&sa[..n]); + addrlen_buf.copy_from_slice(&28u32.to_le_bytes()); + } } } } @@ -7153,7 +7399,7 @@ pub extern "C" fn kernel_connect(fd: i32, addr_ptr: *const u8, addr_len: u32) -> let family = u16::from_le_bytes([addr[0], addr[1]]); if family == 1 { // AF_UNIX — try cross-process connect - match cross_process_unix_connect(proc, fd, addr) { + match cross_process_unix_connect(proc, &mut host, fd, addr) { Ok(()) => 0, Err(e) => -(e as i32), } @@ -7168,6 +7414,12 @@ pub extern "C" fn kernel_connect(fd: i32, addr_ptr: *const u8, addr_len: u32) -> } else { -(Errno::ECONNREFUSED as i32) } + } else if family == 10 && addr_len >= 28 { + // AF_INET6 loopback + match cross_process_loopback_connect6(proc, fd, addr) { + Ok(()) => 0, + Err(e) => -(e as i32), + } } else { -(Errno::ECONNREFUSED as i32) } @@ -7184,7 +7436,8 @@ pub extern "C" fn kernel_connect(fd: i32, addr_ptr: *const u8, addr_len: u32) -> /// target port, then creates global pipe pairs to connect the two processes. fn cross_process_loopback_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Result<(), Errno> { use crate::pipe::PipeBuffer; - use crate::socket::{SocketState, SocketType}; + use crate::socket::{SocketDomain, SocketState, SocketType}; + use wasm_posix_shared::socket::{IPPROTO_IPV6, IPV6_V6ONLY}; let port = u16::from_be_bytes([addr[2], addr[3]]); let ip = [addr[4], addr[5], addr[6], addr[7]]; @@ -7192,23 +7445,20 @@ fn cross_process_loopback_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> R // Get the client socket info let entry = proc.fd_table.get(fd)?; let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } let sock_idx = (-(ofd.host_handle + 1)) as usize; - - // For UDP DGRAM connect, just record peer address (no cross-process search needed) - { - let sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; - if sock.sock_type == SocketType::Dgram { - let client = proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; - client.state = SocketState::Connected; - client.peer_addr = ip; - client.peer_port = port; - return Ok(()); - } + let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + if client_sock.domain != SocketDomain::Inet || client_sock.sock_type != SocketType::Stream { + return Err(Errno::ECONNREFUSED); } // Search ALL processes for a listener on the target port - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let my_pid = proc.pid; + // Do not read through `proc` after reborrowing the containing global + // process table. + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; // Find the listener process and socket index let mut listener_pid: Option = None; @@ -7227,7 +7477,16 @@ fn cross_process_loopback_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> R if s.state == SocketState::Listening && s.bind_port == port && s.sock_type == SocketType::Stream - && (s.bind_addr == [0, 0, 0, 0] || s.bind_addr == [127, 0, 0, 1]) + && match s.domain { + SocketDomain::Inet => { + s.bind_addr == [0, 0, 0, 0] || s.bind_addr == ip + } + SocketDomain::Inet6 => { + s.bind_addr6 == [0; 16] + && s.get_option(IPPROTO_IPV6, IPV6_V6ONLY).unwrap_or(0) == 0 + } + SocketDomain::Unix => false, + } { listener_pid = Some(pid); listener_sock_idx = Some(idx); @@ -7296,7 +7555,11 @@ fn cross_process_loopback_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> R let pc = crate::socket::PendingConnection { peer_addr: client_addr, + peer_addr6: [0; 16], + peer_is_ipv6: false, peer_port: client_port, + peer_pid: 0, + peer_sock_idx: None, recv_pipe_idx: pipe_a_idx, // server reads client's writes send_pipe_idx: pipe_b_idx, // server writes to client's reads }; @@ -7311,28 +7574,182 @@ fn cross_process_loopback_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> R Ok(()) } +/// Cross-process AF_INET6 loopback connect. The listener's shared backlog +/// carries a native IPv6 peer address so accept/getpeername retain the family +/// and do not collapse the connection onto the IPv4 host bridge. +fn cross_process_loopback_connect6( + proc: &mut Process, + fd: i32, + addr: &[u8], +) -> Result<(), Errno> { + use crate::pipe::PipeBuffer; + use crate::socket::{SocketDomain, SocketState, SocketType}; + + let port = u16::from_be_bytes([addr[2], addr[3]]); + let mut ip = [0u8; 16]; + ip.copy_from_slice(&addr[8..24]); + let loopback = [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1]; + if ip == [0; 16] { + ip = loopback; + } + if ip != loopback { + return Err(Errno::ECONNREFUSED); + } + + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let client_sock = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + if client_sock.domain != SocketDomain::Inet6 || client_sock.sock_type != SocketType::Stream { + return Err(Errno::ECONNREFUSED); + } + + let my_pid = proc.pid; + // Do not read through `proc` after reborrowing the containing global + // process table. + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let mut listener = None; + for (&pid, target_proc) in table.processes.iter().rev() { + if pid == my_pid { + continue; + } + for idx in 0..target_proc.sockets.len() { + let Some(sock) = target_proc.sockets.get(idx) else { + continue; + }; + if sock.domain == SocketDomain::Inet6 + && sock.sock_type == SocketType::Stream + && sock.state == SocketState::Listening + && sock.bind_port == port + && (sock.bind_addr6 == [0; 16] || sock.bind_addr6 == ip) + { + listener = Some((pid, idx)); + break; + } + } + if listener.is_some() { + break; + } + } + let (listener_pid, listener_sock_idx) = listener.ok_or(Errno::ECONNREFUSED)?; + + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe_a_idx = pipe_table.alloc(PipeBuffer::new(65536)); + let pipe_b_idx = pipe_table.alloc(PipeBuffer::new(65536)); + + let client_proc = table.get_mut(my_pid).ok_or(Errno::ESRCH)?; + let client_sock = client_proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + let client_addr6 = if client_sock.bind_addr6 == [0; 16] { + loopback + } else { + client_sock.bind_addr6 + }; + let mut client_port = client_sock.bind_port; + if client_port == 0 { + client_port = client_proc.next_ephemeral_port; + client_proc.next_ephemeral_port = client_proc.next_ephemeral_port.wrapping_add(1); + if client_proc.next_ephemeral_port == 0 { + client_proc.next_ephemeral_port = 49152; + } + } + let client = client_proc.sockets.get_mut(sock_idx).ok_or(Errno::EBADF)?; + client.send_buf_idx = Some(pipe_a_idx); + client.recv_buf_idx = Some(pipe_b_idx); + client.state = SocketState::Connected; + client.peer_addr6 = ip; + client.peer_port = port; + client.global_pipes = true; + if client.bind_port == 0 { + client.bind_addr6 = loopback; + client.bind_port = client_port; + } + + let listener_proc = table.get_mut(listener_pid).ok_or(Errno::ESRCH)?; + let listener_sock = listener_proc + .sockets + .get(listener_sock_idx) + .ok_or(Errno::ECONNREFUSED)?; + let shared_idx = listener_sock + .shared_backlog_idx + .ok_or(Errno::ECONNREFUSED)?; + let accept_wake_idx = listener_sock.accept_wake_idx; + let pending = crate::socket::PendingConnection { + peer_addr: [0; 4], + peer_addr6: client_addr6, + peer_is_ipv6: true, + peer_port: client_port, + peer_pid: 0, + peer_sock_idx: None, + recv_pipe_idx: pipe_a_idx, + send_pipe_idx: pipe_b_idx, + }; + if !unsafe { crate::socket::shared_listener_backlog_table().push(shared_idx, pending) } { + return Err(Errno::ECONNREFUSED); + } + if let Some(idx) = accept_wake_idx { + crate::wakeup::push_accept(idx); + } + Ok(()) +} + /// Cross-process AF_UNIX connect. /// /// Looks up the target path in the global UnixSocketRegistry, then creates /// global pipe pairs to connect the client (current process) to the listener /// (possibly in a different process). -fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Result<(), Errno> { +fn cross_process_unix_connect( + proc: &mut Process, + host: &mut dyn HostIO, + fd: i32, + addr: &[u8], +) -> Result<(), Errno> { use crate::pipe::PipeBuffer; - use crate::socket::{SocketDomain, SocketInfo, SocketState, SocketType}; + use crate::socket::{SocketDomain, SocketState, SocketType}; // Parse path from sockaddr_un if addr.len() < 3 { return Err(Errno::EINVAL); } let path_bytes = &addr[2..]; - let path_end = path_bytes - .iter() - .position(|&b| b == 0) - .unwrap_or(path_bytes.len()); - if path_end == 0 { + let resolved = if path_bytes.first().copied() == Some(0) { + if path_bytes.len() < 2 { + return Err(Errno::ECONNREFUSED); + } + path_bytes.to_vec() + } else { + let path_end = path_bytes + .iter() + .position(|&b| b == 0) + .unwrap_or(path_bytes.len()); + if path_end == 0 { + return Err(Errno::ECONNREFUSED); + } + syscalls::resolve_existing_namespace_path(proc, host, &path_bytes[..path_end])? + }; + + // Only AF_UNIX stream sockets can enter the cross-process stream-pipe + // connection path. In particular, never reinterpret a datagram socket as + // a stream merely because its registry lookup found another process. + let entry = proc.fd_table.get(fd)?; + let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; + if ofd.file_type != FileType::Socket { + return Err(Errno::ENOTSOCK); + } + let sock_idx = (-(ofd.host_handle + 1)) as usize; + let client = proc.sockets.get(sock_idx).ok_or(Errno::EBADF)?; + // sys_connect already returned ECONNREFUSED for this attempt. Datagram + // routing is deliberately not retried through the stream-only + // cross-process path: doing so would rewrite the truthful datagram error + // to EPROTOTYPE at the exported syscall boundary. + if client.domain == SocketDomain::Unix && client.sock_type == SocketType::Dgram { return Err(Errno::ECONNREFUSED); } - let resolved = crate::path::resolve_path(&path_bytes[..path_end], &proc.cwd); + if client.domain != SocketDomain::Unix || client.sock_type != SocketType::Stream { + return Err(Errno::EPROTOTYPE); + } // Look up in global registry let registry = unsafe { crate::unix_socket::global_unix_socket_registry() }; @@ -7340,19 +7757,11 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul let listener_pid = entry.pid; let listener_sock_idx = entry.sock_idx; - // Get client socket info - let entry = proc.fd_table.get(fd)?; - let ofd = proc.ofd_table.get(entry.ofd_ref.0).ok_or(Errno::EBADF)?; - let sock_idx = (-(ofd.host_handle + 1)) as usize; - - // Allocate global pipe pair - let pipe_table = unsafe { crate::pipe::global_pipe_table() }; - let pipe_a_idx = pipe_table.alloc(PipeBuffer::new(65536)); - let pipe_b_idx = pipe_table.alloc(PipeBuffer::new(65536)); - // Access process table for cross-process operation - let table = unsafe { &mut *PROCESS_TABLE.0.get() }; let my_pid = proc.pid; + // Do not read through `proc` after reborrowing the containing global + // process table. + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; // Verify listener exists and is listening let listener_proc = table.get_mut(listener_pid).ok_or(Errno::ECONNREFUSED)?; @@ -7360,25 +7769,38 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul .sockets .get(listener_sock_idx) .ok_or(Errno::ECONNREFUSED)?; - if listener.state != SocketState::Listening { + if listener.domain != SocketDomain::Unix + || listener.sock_type != SocketType::Stream + || listener.state != SocketState::Listening + { return Err(Errno::ECONNREFUSED); } + let shared_idx = listener + .shared_backlog_idx + .ok_or(Errno::ECONNREFUSED)?; + let accept_wake_idx = listener.accept_wake_idx; - // Create accepted socket in the listener's process - let mut accepted_sock = SocketInfo::new(SocketDomain::Unix, SocketType::Stream, 0); - accepted_sock.state = SocketState::Connected; - accepted_sock.recv_buf_idx = Some(pipe_a_idx); - accepted_sock.send_buf_idx = Some(pipe_b_idx); - accepted_sock.global_pipes = true; - let accepted_idx = listener_proc.sockets.alloc(accepted_sock); + // Allocate pipes only after both endpoints have been validated, so a + // stale or wrong-type registry entry cannot leak global pipe slots. + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe_a_idx = pipe_table.alloc(PipeBuffer::new(65536)); + let pipe_b_idx = pipe_table.alloc(PipeBuffer::new(65536)); - // Push to listener's backlog - let listener = listener_proc - .sockets - .get_mut(listener_sock_idx) - .ok_or(Errno::EBADF)?; - listener.listen_backlog.push(accepted_idx); - let accept_wake_idx = listener.accept_wake_idx; + let pending = crate::socket::PendingConnection { + peer_addr: [0; 4], + peer_addr6: [0; 16], + peer_is_ipv6: false, + peer_port: 0, + peer_pid: my_pid, + peer_sock_idx: Some(sock_idx), + recv_pipe_idx: pipe_a_idx, + send_pipe_idx: pipe_b_idx, + }; + if !unsafe { crate::socket::shared_listener_backlog_table().push(shared_idx, pending) } { + pipe_table.discard_unclaimed(pipe_a_idx); + pipe_table.discard_unclaimed(pipe_b_idx); + return Err(Errno::ECONNREFUSED); + } // Set up client socket (in current process) let client_proc = table.get_mut(my_pid).ok_or(Errno::ESRCH)?; @@ -7386,6 +7808,7 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul client.send_buf_idx = Some(pipe_a_idx); client.recv_buf_idx = Some(pipe_b_idx); client.state = SocketState::Connected; + client.peer_idx = None; client.global_pipes = true; if let Some(idx) = accept_wake_idx { @@ -7395,6 +7818,74 @@ fn cross_process_unix_connect(proc: &mut Process, fd: i32, addr: &[u8]) -> Resul Ok(()) } +#[cfg(test)] +mod socket_wrapper_tests { + use super::{WasmHostIO, cross_process_unix_connect, write_getsockopt_bytes}; + use crate::errno::Errno; + use crate::fd::OpenFileDescRef; + use crate::ofd::FileType; + use crate::process::Process; + use crate::socket::{SocketDomain, SocketInfo, SocketType}; + use wasm_posix_shared::flags::O_RDWR; + + #[test] + fn unix_datagram_cross_process_retry_preserves_connrefused() { + let mut proc = Process::new(9040); + let sock_idx = + proc.sockets + .alloc(SocketInfo::new(SocketDomain::Unix, SocketType::Dgram, 0)); + let ofd_idx = proc.ofd_table.create( + FileType::Socket, + O_RDWR, + -((sock_idx as i64) + 1), + b"/dev/socket".to_vec(), + ); + let fd = proc + .fd_table + .alloc(OpenFileDescRef(ofd_idx), 0) + .unwrap(); + // Abstract names do not touch HostIO, which keeps this wrapper test + // focused on the retry's socket-type guard. + let addr = [1, 0, 0, b'm', b'i', b's', b's']; + let mut host = WasmHostIO; + + assert_eq!( + cross_process_unix_connect(&mut proc, &mut host, fd, &addr), + Err(Errno::ECONNREFUSED), + ); + } + + #[test] + fn getsockopt_copy_honors_short_and_unaligned_lengths() { + let mut out = [0xaau8; 4]; + let mut length = 2u32; + write_getsockopt_bytes(out.as_mut_ptr(), &mut length, &[1, 2, 3, 4]).unwrap(); + assert_eq!(out, [1, 2, 0xaa, 0xaa]); + assert_eq!(length, 2); + + let mut unaligned_storage = [0u8; 5]; + unaligned_storage[1..5].copy_from_slice(&3u32.to_ne_bytes()); + let unaligned_length = unsafe { unaligned_storage.as_mut_ptr().add(1).cast::() }; + write_getsockopt_bytes(out.as_mut_ptr(), unaligned_length, &[5, 6, 7, 8]).unwrap(); + assert_eq!(&out[..3], &[5, 6, 7]); + assert_eq!(&unaligned_storage[1..5], &3u32.to_ne_bytes()); + } + + #[test] + fn getsockopt_copy_rejects_null_guest_pointers() { + let mut out = [0u8; 4]; + let mut length = 4u32; + assert_eq!( + write_getsockopt_bytes(core::ptr::null_mut(), &mut length, &[1, 2, 3, 4]), + Err(Errno::EFAULT), + ); + assert_eq!( + write_getsockopt_bytes(out.as_mut_ptr(), core::ptr::null_mut(), &[1, 2, 3, 4]), + Err(Errno::EFAULT), + ); + } +} + /// Send data on a socket. Returns bytes sent or negative errno. #[unsafe(no_mangle)] pub extern "C" fn kernel_send(fd: i32, buf_ptr: *const u8, buf_len: u32, flags: u32) -> i32 { @@ -7414,7 +7905,13 @@ pub extern "C" fn kernel_send(fd: i32, buf_ptr: *const u8, buf_len: u32, flags: pub extern "C" fn kernel_recv(fd: i32, buf_ptr: *mut u8, buf_len: u32, flags: u32) -> i32 { let (_gkl, proc) = unsafe { get_process() }; let mut host = WasmHostIO; - let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; + // A zero-length output is not assigned scratch space by the host. Avoid + // treating the unchanged guest pointer as a kernel-memory pointer. + let buf = if buf_len == 0 { + &mut [] + } else { + unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) } + }; let result = match syscalls::sys_recv(proc, &mut host, fd, buf, flags) { Ok(n) => n as i32, Err(e) => -(e as i32), @@ -7436,6 +7933,30 @@ pub extern "C" fn kernel_shutdown(fd: i32, how: u32) -> i32 { result } +/// Copy a socket option to the caller without exceeding its guest buffer. +/// +/// `optlen` is a value-result parameter: the input bounds the copy and the +/// output reports the number of bytes actually copied, including truncation. +/// Both pointers are required for these option shapes. +fn write_getsockopt_bytes( + optval_ptr: *mut u8, + optlen_ptr: *mut u32, + value: &[u8], +) -> Result<(), Errno> { + if optval_ptr.is_null() || optlen_ptr.is_null() { + return Err(Errno::EFAULT); + } + + let available = unsafe { core::ptr::read_unaligned(optlen_ptr) as usize }; + let write_len = available.min(value.len()); + if write_len > 0 { + let out = unsafe { slice::from_raw_parts_mut(optval_ptr, write_len) }; + out.copy_from_slice(&value[..write_len]); + } + unsafe { core::ptr::write_unaligned(optlen_ptr, write_len as u32) }; + Ok(()) +} + /// Get socket option. Returns 0 on success, negative errno on error. /// Writes the option value to optval_ptr. optlen_ptr points to buffer size /// on input, receives actual written size on output. @@ -7453,25 +7974,28 @@ pub extern "C" fn kernel_getsockopt( // Handle struct tcp_info (TCP_INFO) if level == IPPROTO_TCP && optname == TCP_INFO { let result = match syscalls::sys_getsockopt_tcp_info(proc, fd) { - Ok(info_buf) => { - let avail = if !optlen_ptr.is_null() { - unsafe { *optlen_ptr as usize } - } else { - syscalls::TCP_INFO_SIZE - }; - let write_len = if avail < syscalls::TCP_INFO_SIZE { - avail - } else { - syscalls::TCP_INFO_SIZE - }; - let out = unsafe { slice::from_raw_parts_mut(optval_ptr, write_len) }; - out.copy_from_slice(&info_buf[..write_len]); - if !optlen_ptr.is_null() { - unsafe { - *optlen_ptr = write_len as u32; - } + Ok(info_buf) => match write_getsockopt_bytes(optval_ptr, optlen_ptr, &info_buf) { + Ok(()) => 0, + Err(e) => -(e as i32), + }, + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } + + // Handle struct linger (SO_LINGER). + if level == SOL_SOCKET && optname == SO_LINGER { + let result = match syscalls::sys_getsockopt_linger(proc, fd) { + Ok((l_onoff, l_linger)) => { + let mut tmp = [0u8; 8]; + tmp[0..4].copy_from_slice(&l_onoff.to_le_bytes()); + tmp[4..8].copy_from_slice(&l_linger.to_le_bytes()); + match write_getsockopt_bytes(optval_ptr, optlen_ptr, &tmp) { + Ok(()) => 0, + Err(e) => -(e as i32), } - 0 } Err(e) => -(e as i32), }; @@ -7480,21 +8004,57 @@ pub extern "C" fn kernel_getsockopt( return result; } - // Handle struct timeval options (SO_RCVTIMEO, SO_SNDTIMEO) - if level == SOL_SOCKET && (optname == SO_RCVTIMEO || optname == SO_SNDTIMEO) { - let result = match syscalls::sys_getsockopt_timeout(proc, fd, optname) { + // Handle string-valued SO_BINDTODEVICE. + if level == SOL_SOCKET && optname == SO_BINDTODEVICE { + let result = match syscalls::sys_getsockopt_bindtodevice(proc, fd) { + Ok(name) => { + let mut value = name; + if !value.is_empty() { + value.push(0); + } + match write_getsockopt_bytes(optval_ptr, optlen_ptr, &value) { + Ok(()) => 0, + Err(e) => -(e as i32), + } + } + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } + + // Handle string-valued TCP_CONGESTION. + if level == IPPROTO_TCP && optname == TCP_CONGESTION { + let result = match syscalls::sys_getsockopt_tcp_congestion(proc, fd) { + Ok(mut name) => { + name.push(0); + match write_getsockopt_bytes(optval_ptr, optlen_ptr, &name) { + Ok(()) => 0, + Err(e) => -(e as i32), + } + } + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } + + // Handle struct timeval options (SO_RCVTIMEO, SO_SNDTIMEO), accepting + // both musl's wasm32 time64 and wasm64 long64 option numbers. + if let Some(timeout_optname) = syscalls::canonical_socket_timeout_optname(level, optname) { + let result = match syscalls::sys_getsockopt_timeout(proc, fd, timeout_optname) { Ok(timeout_us) => { let tv_sec = (timeout_us / 1_000_000) as i64; let tv_usec = (timeout_us % 1_000_000) as i64; - let buf = unsafe { slice::from_raw_parts_mut(optval_ptr, 16) }; - buf[0..8].copy_from_slice(&tv_sec.to_le_bytes()); - buf[8..16].copy_from_slice(&tv_usec.to_le_bytes()); - if !optlen_ptr.is_null() { - unsafe { - *optlen_ptr = 16; - } + let mut value = [0u8; 16]; + value[0..8].copy_from_slice(&tv_sec.to_le_bytes()); + value[8..16].copy_from_slice(&tv_usec.to_le_bytes()); + match write_getsockopt_bytes(optval_ptr, optlen_ptr, &value) { + Ok(()) => 0, + Err(e) => -(e as i32), } - 0 } Err(e) => -(e as i32), }; @@ -7505,17 +8065,10 @@ pub extern "C" fn kernel_getsockopt( let result = match syscalls::sys_getsockopt(proc, fd, level, optname) { Ok(val) => { - // Write as u32 (4 bytes) for int-valued options - let val_ptr = optval_ptr as *mut u32; - unsafe { - *val_ptr = val; - } - if !optlen_ptr.is_null() { - unsafe { - *optlen_ptr = 4; - } + match write_getsockopt_bytes(optval_ptr, optlen_ptr, &val.to_le_bytes()) { + Ok(()) => 0, + Err(e) => -(e as i32), } - 0 } Err(e) => -(e as i32), }; @@ -7538,8 +8091,9 @@ pub extern "C" fn kernel_setsockopt( let (_gkl, proc) = unsafe { get_process() }; // Handle struct timeval options (SO_RCVTIMEO, SO_SNDTIMEO). - // On wasm32 time64: struct timeval = { i64 tv_sec, i64 tv_usec } = 16 bytes. - if level == SOL_SOCKET && (optname == SO_RCVTIMEO || optname == SO_SNDTIMEO) { + // Both supported ABIs use two 64-bit fields here: wasm32 uses time64, + // while wasm64 uses native 64-bit long. Their option numbers differ. + if let Some(timeout_optname) = syscalls::canonical_socket_timeout_optname(level, optname) { if optval_ptr.is_null() || optlen < 16 { let mut host = WasmHostIO; deliver_pending_signals(proc, &mut host); @@ -7554,7 +8108,26 @@ pub extern "C" fn kernel_setsockopt( return -(Errno::EINVAL as i32); } let timeout_us = (tv_sec as u64) * 1_000_000 + (tv_usec as u64); - let result = match syscalls::sys_setsockopt_timeout(proc, fd, optname, timeout_us) { + let result = match syscalls::sys_setsockopt_timeout(proc, fd, timeout_optname, timeout_us) { + Ok(()) => 0, + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } + + // Handle struct linger (SO_LINGER). + if level == SOL_SOCKET && optname == SO_LINGER { + if optval_ptr.is_null() || optlen < 8 { + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return -(Errno::EINVAL as i32); + } + let buf = unsafe { slice::from_raw_parts(optval_ptr, 8) }; + let l_onoff = i32::from_le_bytes(buf[0..4].try_into().unwrap()); + let l_linger = i32::from_le_bytes(buf[4..8].try_into().unwrap()); + let result = match syscalls::sys_setsockopt_linger(proc, fd, l_onoff, l_linger) { Ok(()) => 0, Err(e) => -(e as i32), }; @@ -7563,6 +8136,162 @@ pub extern "C" fn kernel_setsockopt( return result; } + // Handle string-valued SO_BINDTODEVICE. + if level == SOL_SOCKET && optname == SO_BINDTODEVICE { + if optval_ptr.is_null() { + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return -(Errno::EFAULT as i32); + } + let buf = unsafe { slice::from_raw_parts(optval_ptr, optlen as usize) }; + let result = match syscalls::sys_setsockopt_bindtodevice(proc, fd, buf) { + Ok(()) => 0, + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } + + // Handle string-valued TCP_CONGESTION. + if level == IPPROTO_TCP && optname == TCP_CONGESTION { + if optval_ptr.is_null() { + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return -(Errno::EFAULT as i32); + } + let buf = unsafe { slice::from_raw_parts(optval_ptr, optlen as usize) }; + let result = match syscalls::sys_setsockopt_tcp_congestion(proc, fd, buf) { + Ok(()) => 0, + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } + + // Handle IPv4 multicast membership/source-filter options. These options + // carry struct ip_mreq/ip_mreq_source/group_req/group_source_req payloads, + // not plain integers, so the wrapper must parse the guest ABI buffer. + if level == IPPROTO_IP + && matches!( + optname, + IP_ADD_MEMBERSHIP + | IP_DROP_MEMBERSHIP + | IP_BLOCK_SOURCE + | IP_UNBLOCK_SOURCE + | IP_ADD_SOURCE_MEMBERSHIP + | IP_DROP_SOURCE_MEMBERSHIP + | MCAST_JOIN_GROUP + | MCAST_LEAVE_GROUP + | MCAST_BLOCK_SOURCE + | MCAST_UNBLOCK_SOURCE + | MCAST_JOIN_SOURCE_GROUP + | MCAST_LEAVE_SOURCE_GROUP + ) + { + if optval_ptr.is_null() { + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return -(Errno::EFAULT as i32); + } + let buf = unsafe { slice::from_raw_parts(optval_ptr, optlen as usize) }; + + let parse_sockaddr_in_at = |offset: usize| -> Result<[u8; 4], Errno> { + if buf.len() < offset + 8 { + return Err(Errno::EINVAL); + } + let family = u16::from_le_bytes([buf[offset], buf[offset + 1]]); + if family as u32 != AF_INET { + return Err(Errno::EAFNOSUPPORT); + } + Ok([ + buf[offset + 4], + buf[offset + 5], + buf[offset + 6], + buf[offset + 7], + ]) + }; + + let parse_ifindex_at = |offset: usize| -> Result<[u8; 4], Errno> { + if buf.len() < offset + 4 { + return Err(Errno::EINVAL); + } + let ifindex = u32::from_le_bytes(buf[offset..offset + 4].try_into().unwrap()); + syscalls::ipv4_multicast_interface_from_index(ifindex) + }; + + let parsed = (|| -> Result<([u8; 4], [u8; 4], Option<[u8; 4]>), Errno> { + match optname { + IP_ADD_MEMBERSHIP | IP_DROP_MEMBERSHIP => { + if buf.len() < 8 { + Err(Errno::EINVAL) + } else { + Ok(( + [buf[0], buf[1], buf[2], buf[3]], + [buf[4], buf[5], buf[6], buf[7]], + None, + )) + } + } + IP_BLOCK_SOURCE + | IP_UNBLOCK_SOURCE + | IP_ADD_SOURCE_MEMBERSHIP + | IP_DROP_SOURCE_MEMBERSHIP => { + if buf.len() < 12 { + Err(Errno::EINVAL) + } else { + Ok(( + [buf[0], buf[1], buf[2], buf[3]], + [buf[4], buf[5], buf[6], buf[7]], + Some([buf[8], buf[9], buf[10], buf[11]]), + )) + } + } + MCAST_JOIN_GROUP | MCAST_LEAVE_GROUP => { + let (group_offset, _) = + syscalls::multicast_group_request_offsets(buf, false)?; + Ok(( + parse_sockaddr_in_at(group_offset)?, + parse_ifindex_at(0)?, + None, + )) + } + MCAST_BLOCK_SOURCE + | MCAST_UNBLOCK_SOURCE + | MCAST_JOIN_SOURCE_GROUP + | MCAST_LEAVE_SOURCE_GROUP => { + let (group_offset, source_offset) = + syscalls::multicast_group_request_offsets(buf, true)?; + Ok(( + parse_sockaddr_in_at(group_offset)?, + parse_ifindex_at(0)?, + Some(parse_sockaddr_in_at( + source_offset.expect("source request has source offset"), + )?), + )) + } + _ => unreachable!(), + } + })(); + + let result = match parsed.and_then(|(group, interface_addr, source)| { + syscalls::sys_setsockopt_ipv4_multicast( + proc, + fd, + optname, + group, + interface_addr, + source, + ) + }) { + Ok(()) => 0, + Err(e) => -(e as i32), + }; + let mut host = WasmHostIO; + deliver_pending_signals(proc, &mut host); + return result; + } // Read first 4 bytes as u32 value (covers most int-valued options) let optval = if !optval_ptr.is_null() && optlen >= 4 { let buf = unsafe { slice::from_raw_parts(optval_ptr, 4) }; @@ -7641,7 +8370,14 @@ pub extern "C" fn kernel_recvfrom( ) -> i32 { let (_gkl, proc) = unsafe { get_process() }; let mut host = WasmHostIO; - let buf = unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; + // The generic host marshaller leaves a zero-sized pointer unadjusted. + // Construct a real empty slice instead of interpreting that guest-memory + // address inside the kernel instance. + let buf = if buf_len == 0 { + &mut [] + } else { + unsafe { slice::from_raw_parts_mut(buf_ptr, buf_len as usize) } + }; // addrlen_ptr is a channel-rewritten pointer to a u32 containing the buffer size let addr_len = if !addrlen_ptr.is_null() { unsafe { *addrlen_ptr } @@ -8026,7 +8762,7 @@ pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 break 'done -(Errno::EINVAL as i32); } - let mut total: usize = 0; + let mut buffers = Vec::with_capacity(iovcnt as usize); for i in 0..iovcnt as usize { let iov = unsafe { iov_ptr.add(i * 8) }; let base = unsafe { u32::from_le_bytes([*iov, *iov.add(1), *iov.add(2), *iov.add(3)]) }; @@ -8036,23 +8772,12 @@ pub extern "C" fn kernel_writev(fd: i32, iov_ptr: *const u8, iovcnt: i32) -> i32 if len == 0 { continue; } - let buf = unsafe { slice::from_raw_parts(base as *const u8, len as usize) }; - match syscalls::sys_write(proc, &mut host, fd, buf) { - Ok(n) => { - total += n; - if n < len as usize { - break; - } - } - Err(e) => { - if total > 0 { - break 'done total as i32; - } - break 'done -(e as i32); - } - } + buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len as usize) }); + } + match syscalls::sys_writev(proc, &mut host, fd, &buffers) { + Ok(n) => n as i32, + Err(e) => -(e as i32), } - total as i32 }; deliver_pending_signals(proc, &mut host); result @@ -8179,8 +8904,7 @@ pub extern "C" fn kernel_pwritev( break 'done -(Errno::EINVAL as i32); } - let mut total: usize = 0; - let mut cur_offset = offset; + let mut buffers = Vec::with_capacity(iovcnt as usize); for i in 0..iovcnt as usize { let iov = unsafe { iov_ptr.add(i * 8) }; let base = unsafe { u32::from_le_bytes([*iov, *iov.add(1), *iov.add(2), *iov.add(3)]) }; @@ -8190,24 +8914,12 @@ pub extern "C" fn kernel_pwritev( if len == 0 { continue; } - let buf = unsafe { slice::from_raw_parts(base as *const u8, len as usize) }; - match syscalls::sys_pwrite(proc, &mut host, fd, buf, cur_offset) { - Ok(n) => { - total += n; - cur_offset += n as i64; - if n < len as usize { - break; - } - } - Err(e) => { - if total > 0 { - break 'done total as i32; - } - break 'done -(e as i32); - } - } + buffers.push(unsafe { slice::from_raw_parts(base as *const u8, len as usize) }); + } + match syscalls::sys_pwritev(proc, &mut host, fd, &buffers, offset) { + Ok(n) => n as i32, + Err(e) => -(e as i32), } - total as i32 }; deliver_pending_signals(proc, &mut host); result @@ -8589,9 +9301,12 @@ pub extern "C" fn kernel_setegid(egid: u32) -> i32 { // Phase 12: getrusage // --------------------------------------------------------------------------- -/// getrusage — get resource usage. Writes 144-byte rusage struct. +/// getrusage — get resource usage. Writes the shared fixed-width wire record. #[unsafe(no_mangle)] pub extern "C" fn kernel_getrusage(who: i32, buf_ptr: *mut u8, buf_len: u32) -> i32 { + if buf_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc) = unsafe { get_process() }; let buf = unsafe { core::slice::from_raw_parts_mut(buf_ptr, buf_len as usize) }; let result = match syscalls::sys_getrusage(proc, who, buf) { @@ -8996,11 +9711,86 @@ pub extern "C" fn kernel_getitimer(which: u32, curr_ptr: *mut u8) -> i32 { // POSIX timers (timer_create / timer_settime / timer_gettime / etc.) // --------------------------------------------------------------------------- -/// SIGEV_SIGNAL = 0, SIGEV_NONE = 1. +/// Kernel-facing sigevent notification modes used by musl. const SIGEV_SIGNAL: u32 = 0; +const SIGEV_NONE: u32 = 1; +/// Linux extension used internally by musl to implement POSIX SIGEV_THREAD. +const SIGEV_THREAD_ID: u32 = 4; /// TIMER_ABSTIME flag for timer_settime. const TIMER_ABSTIME: i32 = 1; +fn timer_clock_to_host_clock(clock_id: u32) -> Option { + use wasm_posix_shared::clock::*; + match clock_id { + CLOCK_REALTIME | CLOCK_MONOTONIC => Some(clock_id), + CLOCK_BOOTTIME => Some(CLOCK_MONOTONIC), + _ => None, + } +} + +#[cfg(test)] +mod posix_timer_tests { + use super::*; + use wasm_posix_shared::clock::*; + + #[test] + fn boottime_timers_use_monotonic_host_clock() { + assert_eq!(timer_clock_to_host_clock(CLOCK_BOOTTIME), Some(CLOCK_MONOTONIC)); + } + + #[test] + fn timer_create_rejects_unsupported_clock_ids() { + assert_eq!(timer_clock_to_host_clock(CLOCK_THREAD_CPUTIME_ID), None); + assert_eq!(timer_clock_to_host_clock(99), None); + } + + fn directed_timer(target_tid: u32) -> crate::process::PosixTimerState { + crate::process::PosixTimerState { + clock_id: CLOCK_MONOTONIC, + sigev_signo: 10, + sigev_value: 77, + sigev_notify: SIGEV_THREAD_ID, + sigev_tid: target_tid, + interval_sec: 0, + interval_nsec: 0, + value_sec: 0, + value_nsec: 1, + notification_pending: false, + overrun_current: 0, + overrun_last: 0, + } + } + + #[test] + fn directed_timer_fire_targets_main_and_worker_queues_exactly() { + let mut proc = Process::new(41); + proc.add_thread(crate::process::ThreadInfo::new(42, 0, 0, 0)); + proc.posix_timers.push(Some(directed_timer(41))); + proc.posix_timers.push(Some(directed_timer(42))); + + assert_eq!(queue_posix_timer_fire(&mut proc, 0), 41); + assert!(proc.main_thread_signals.is_pending(10)); + assert!(!proc.get_thread(42).unwrap().signals.is_pending(10)); + + assert_eq!(queue_posix_timer_fire(&mut proc, 1), 42); + assert!(proc.get_thread(42).unwrap().signals.is_pending(10)); + } + + #[test] + fn directed_timer_fire_rejects_a_target_that_died_after_create() { + let mut proc = Process::new(41); + proc.add_thread(crate::process::ThreadInfo::new(42, 0, 0, 0)); + proc.posix_timers.push(Some(directed_timer(42))); + proc.remove_thread(42); + + assert_eq!(queue_posix_timer_fire(&mut proc, 0), -(Errno::ESRCH as i32)); + assert!(!proc.posix_timers[0] + .as_ref() + .unwrap() + .notification_pending); + } +} + /// timer_create(clock_id, sigevent_ptr, timerid_ptr) /// musl sends ksigevent = {sigev_value(i32), sigev_signo(i32), sigev_notify(i32), sigev_tid(i32)} = 16 bytes. /// Returns 0 on success, negative errno. @@ -9014,19 +9804,28 @@ pub extern "C" fn kernel_timer_create( let (_gkl, proc) = unsafe { get_process() }; + let host_clock_id = match timer_clock_to_host_clock(clock_id) { + Some(id) => id, + None => return -(Errno::EINVAL as i32), + }; + // Parse sigevent (default: SIGEV_SIGNAL with SIGALRM) - let (sigev_signo, sigev_value, sigev_notify) = if sevp_ptr.is_null() { - (14u32, 0i32, SIGEV_SIGNAL) // default: SIGALRM + let (sigev_signo, sigev_value, sigev_notify, sigev_tid) = if sevp_ptr.is_null() { + (14u32, 0i32, SIGEV_SIGNAL, 0u32) // default: SIGALRM } else { let buf = unsafe { slice::from_raw_parts(sevp_ptr, 16) }; let value = i32::from_le_bytes(buf[0..4].try_into().unwrap()); let signo = i32::from_le_bytes(buf[4..8].try_into().unwrap()) as u32; let notify = i32::from_le_bytes(buf[8..12].try_into().unwrap()) as u32; - (signo, value, notify) + let tid = i32::from_le_bytes(buf[12..16].try_into().unwrap()) as u32; + (signo, value, notify, tid) }; - // Only SIGEV_SIGNAL and SIGEV_NONE are supported - if sigev_notify != SIGEV_SIGNAL && sigev_notify != 1 { + let sigev_signo = match normalize_posix_timer_signo(sigev_notify, sigev_signo) { + Ok(signo) => signo, + Err(errno) => return -(errno as i32), + }; + if sigev_notify == SIGEV_THREAD_ID && !proc.is_live_explicit_tid(sigev_tid) { return -(Errno::EINVAL as i32); } @@ -9050,14 +9849,18 @@ pub extern "C" fn kernel_timer_create( }; proc.posix_timers[timer_id] = Some(PosixTimerState { - clock_id, + clock_id: host_clock_id, sigev_signo, sigev_value, + sigev_notify, + sigev_tid, interval_sec: 0, interval_nsec: 0, value_sec: 0, value_nsec: 0, - overrun: 0, + notification_pending: false, + overrun_current: 0, + overrun_last: 0, }); if !timerid_ptr.is_null() { @@ -9071,6 +9874,71 @@ pub extern "C" fn kernel_timer_create( 0 } +/// Queue one POSIX timer expiration in the timer owner's signal state. +/// +/// The host owns wall-clock scheduling, but the kernel owns notification +/// semantics and siginfo metadata. Return values tell the host which blocked +/// signal-wait channel to wake: +/// - positive TID: thread-directed `SIGEV_THREAD_ID` +/// - zero: process-wide `SIGEV_SIGNAL` +/// - negative: no notification was queued (SIGEV_NONE, overrun, or error) +#[unsafe(no_mangle)] +pub extern "C" fn kernel_posix_timer_fire(pid: u32, timer_id: u32) -> i32 { + let table = unsafe { &mut *PROCESS_TABLE.0.get() }; + let proc = match table.get_mut(pid) { + Some(proc) => proc, + None => return -(Errno::ESRCH as i32), + }; + queue_posix_timer_fire(proc, timer_id) +} + +fn queue_posix_timer_fire(proc: &mut Process, timer_id: u32) -> i32 { + let (notify, target_tid, signo, value) = { + let timer = match proc.posix_timers.get_mut(timer_id as usize) { + Some(Some(timer)) => timer, + _ => return -(Errno::EINVAL as i32), + }; + if timer.sigev_notify == SIGEV_NONE { + return -(Errno::EAGAIN as i32); + } + if crate::signal::should_discard_pending( + timer.sigev_signo, + &proc.signals.get_handler(timer.sigev_signo), + ) { + return -(Errno::EAGAIN as i32); + } + if timer.notification_pending { + timer.overrun_current = timer.overrun_current.saturating_add(1); + return -(Errno::EAGAIN as i32); + } + timer.notification_pending = true; + ( + timer.sigev_notify, + timer.sigev_tid, + timer.sigev_signo, + timer.sigev_value, + ) + }; + + match notify { + SIGEV_SIGNAL => { + proc.signals.raise_timer(signo, value, timer_id); + 0 + } + SIGEV_THREAD_ID => { + if proc.raise_timer_for_thread(target_tid, signo, value, timer_id) { + target_tid as i32 + } else { + if let Some(Some(timer)) = proc.posix_timers.get_mut(timer_id as usize) { + timer.notification_pending = false; + } + -(Errno::ESRCH as i32) + } + } + _ => -(Errno::EINVAL as i32), + } +} + /// timer_settime(timerid, flags, new_value_ptr, old_value_ptr) /// new/old are itimerspec as 4 × i64 = 32 bytes: {interval_sec, interval_nsec, value_sec, value_nsec}. #[unsafe(no_mangle)] @@ -9115,7 +9983,6 @@ pub extern "C" fn kernel_timer_settime( timer.interval_nsec = int_nsec; timer.value_sec = val_sec; timer.value_nsec = val_nsec; - timer.overrun = 0; // Convert to milliseconds for the host timer. // 0 = disarm, 1+ = armed. @@ -9184,7 +10051,7 @@ pub extern "C" fn kernel_timer_getoverrun(timerid: i32) -> i32 { let tid = timerid as usize; let result = match proc.posix_timers.get(tid) { - Some(Some(t)) => t.overrun, + Some(Some(t)) => t.overrun_last, _ => return -(Errno::EINVAL as i32), }; @@ -9206,39 +10073,23 @@ pub extern "C" fn kernel_timer_delete(timerid: i32) -> i32 { // Disarm the host timer let _ = host.host_set_posix_timer(timerid, 0, 0, 0); + proc.remove_posix_timer_notification(timerid as u32); proc.posix_timers[tid] = None; deliver_pending_signals(proc, &mut host); 0 } -/// Called by the host when a repeating POSIX timer fires to increment the overrun counter. -/// This is used for timer_getoverrun() support. +/// Backward-compatible interval hook for hosts predating +/// `kernel_posix_timer_fire`. Its return contract is unchanged: zero tells the +/// legacy host to queue a process-wide signal, while one suppresses an overrun. #[unsafe(no_mangle)] -/// Called by the host when a POSIX timer's interval fires. -/// If the timer's signal is already pending (blocked), increments overrun and -/// returns 1 (don't send signal again). If not pending, resets overrun to 0 -/// and returns 0 (host should call sendSignalToProcess for a new delivery cycle). pub extern "C" fn kernel_posix_timer_interval_fire(pid: u32, timer_id: u32) -> i32 { let table = unsafe { &mut *PROCESS_TABLE.0.get() }; - if let Some(proc) = table.get_mut(pid) { - if let Some(Some(timer)) = proc.posix_timers.get_mut(timer_id as usize) { - let signo = timer.sigev_signo as u64; - if signo > 0 && signo <= 64 { - let mask = 1u64 << (signo - 1); - if proc.signals.pending & mask != 0 { - // Signal already pending — this is an overrun - timer.overrun += 1; - return 1; - } else { - // New delivery cycle — reset overrun - timer.overrun = 0; - return 0; - } - } - } + match table.get_mut(pid) { + Some(proc) => proc.note_legacy_posix_timer_interval_fire(timer_id) as i32, + None => 0, } - 0 } // --------------------------------------------------------------------------- @@ -9284,7 +10135,7 @@ pub extern "C" fn kernel_rt_sigtimedwait(mask_lo: u32, mask_hi: u32, timeout_ms: let mut host = WasmHostIO; let mask = ((mask_hi as u64) << 32) | (mask_lo as u64); let result = match syscalls::sys_sigtimedwait(proc, &mut host, mask, timeout_ms) { - Ok((sig, _si_value, _si_code)) => sig as i32, + Ok((sig, ..)) => sig as i32, Err(e) => -(e as i32), }; deliver_pending_signals(proc, &mut host); @@ -9293,27 +10144,44 @@ pub extern "C" fn kernel_rt_sigtimedwait(mask_lo: u32, mask_hi: u32, timeout_ms: /// pathconf -- get configurable pathname variable for a path. #[unsafe(no_mangle)] -pub extern "C" fn kernel_pathconf(path_ptr: *const u8, path_len: u32, name: i32) -> i64 { +pub extern "C" fn kernel_pathconf( + path_ptr: *const u8, + path_len: u32, + name: i32, + value_ptr: *mut i64, +) -> i32 { + if path_ptr.is_null() || value_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc) = unsafe { get_process() }; let path = unsafe { core::slice::from_raw_parts(path_ptr, path_len as usize) }; - let result = match syscalls::sys_pathconf(path, name) { - Ok(v) => v, - Err(e) => -(e as i64), - }; let mut host = WasmHostIO; + let result = match syscalls::sys_pathconf(proc, &mut host, path, name) { + Ok(value) => { + unsafe { core::ptr::write_unaligned(value_ptr, value.unwrap_or(-1)) }; + 0 + } + Err(e) => -(e as i32), + }; deliver_pending_signals(proc, &mut host); result } /// fpathconf -- get configurable pathname variable for an open fd. #[unsafe(no_mangle)] -pub extern "C" fn kernel_fpathconf(fd: i32, name: i32) -> i64 { +pub extern "C" fn kernel_fpathconf(fd: i32, name: i32, value_ptr: *mut i64) -> i32 { + if value_ptr.is_null() { + return -(Errno::EFAULT as i32); + } let (_gkl, proc) = unsafe { get_process() }; - let result = match syscalls::sys_fpathconf(proc, fd, name) { - Ok(v) => v, - Err(e) => -(e as i64), - }; let mut host = WasmHostIO; + let result = match syscalls::sys_fpathconf(proc, &mut host, fd, name) { + Ok(value) => { + unsafe { core::ptr::write_unaligned(value_ptr, value.unwrap_or(-1)) }; + 0 + } + Err(e) => -(e as i32), + }; deliver_pending_signals(proc, &mut host); result } @@ -9601,7 +10469,7 @@ pub extern "C" fn kernel_inject_connection( } let shared_idx = match sock.shared_backlog_idx { Some(i) => i, - // Should always be set for AF_INET listeners (sys_listen allocates + // Should always be set for AF_INET/AF_INET6 listeners (sys_listen allocates // it). Defensive: refuse the inject rather than fall back to a // per-process backlog that fork siblings can't see. None => return -(Errno::EINVAL as i32), @@ -9624,15 +10492,19 @@ pub extern "C" fn kernel_inject_connection( peer_addr_c as u8, peer_addr_d as u8, ], + peer_addr6: [0; 16], + peer_is_ipv6: false, peer_port: peer_port as u16, + peer_pid: 0, + peer_sock_idx: None, recv_pipe_idx, send_pipe_idx, }; let pushed = unsafe { crate::socket::shared_listener_backlog_table().push(shared_idx, pc) }; if !pushed { // Slot was freed (last listener closed concurrently) — release pipes - pipe_table.free_if_closed(recv_pipe_idx); - pipe_table.free_if_closed(send_pipe_idx); + pipe_table.discard_unclaimed(recv_pipe_idx); + pipe_table.discard_unclaimed(send_pipe_idx); return -(Errno::EBADF as i32); } @@ -9781,7 +10653,7 @@ pub extern "C" fn kernel_pipe_is_write_open(_pid: u32, pipe_idx: u32) -> i32 { if pipe.is_write_end_open() { 1 } else { 0 } } -/// Check if a pipe's read end is still open. +/// Check if a pipe accepts writes through a real reader or TCP discard sink. /// Returns 1 if open, 0 if closed, negative errno on error. /// /// `pid` is ignored for ABI compatibility; `pipe_idx` addresses the global @@ -9796,6 +10668,22 @@ pub extern "C" fn kernel_pipe_is_read_open(_pid: u32, pipe_idx: u32) -> i32 { if pipe.is_read_end_open() { 1 } else { 0 } } +/// Check if a pipe has at least one application-owned reader. +/// Returns 1 for a real reader, 0 for a TCP discard sink or closed read end, +/// and negative errno on error. +/// +/// `pid` is ignored for ABI compatibility; `pipe_idx` addresses the global +/// pipe table. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_pipe_has_readers(_pid: u32, pipe_idx: u32) -> i32 { + let pipe_table = unsafe { crate::pipe::global_pipe_table() }; + let pipe = match pipe_table.get(pipe_idx as usize) { + Some(p) => p, + None => return -(Errno::EBADF as i32), + }; + if pipe.has_readers() { 1 } else { 0 } +} + /// Look up the recv pipe index for a socket fd. /// Returns the recv_buf_idx or -1 if the fd is not a connected socket. #[unsafe(no_mangle)] @@ -9870,34 +10758,31 @@ pub extern "C" fn kernel_get_fd_pipe_idx(pid: u32, fd: i32) -> i32 { /// Returns -1 if the fd is not a listening socket with a wake token. #[unsafe(no_mangle)] pub extern "C" fn kernel_get_fd_accept_wake_idx(pid: u32, fd: i32) -> i32 { - use crate::ofd::FileType; - use crate::socket::SocketState; - let table = unsafe { &*PROCESS_TABLE.0.get() }; - let proc = match table.get(pid) { - Some(p) => p, - None => return -1, - }; - let entry = match proc.fd_table.get(fd) { - Ok(e) => e, - Err(_) => return -1, - }; - let ofd = match proc.ofd_table.get(entry.ofd_ref.0) { - Some(o) => o, - None => return -1, - }; - if ofd.file_type != FileType::Socket { + let Some(proc) = table.get(pid) else { return -1; - } - let sock_idx = (-(ofd.host_handle + 1)) as usize; - let sock = match proc.sockets.get(sock_idx) { - Some(s) => s, - None => return -1, }; - if sock.state != SocketState::Listening { + let Ok(entry) = proc.fd_table.get(fd) else { return -1; - } - sock.accept_wake_idx.map(|idx| idx as i32).unwrap_or(-1) + }; + syscalls::listener_accept_wake_for_entry(proc, entry) + .map(|idx| idx as i32) + .unwrap_or(-1) +} + +/// Find the lowest live listener fd carrying `wake_idx` in `pid`. +/// +/// The wake token identifies the shared listener/open-description state across +/// descriptor aliases. Iterating the kernel fd table lets the host remap a +/// listener mirror after exec closes a CLOEXEC alias without guessing the +/// process's current `RLIMIT_NOFILE` ceiling. +#[unsafe(no_mangle)] +pub extern "C" fn kernel_find_listener_fd_by_accept_wake(pid: u32, wake_idx: u32) -> i32 { + let table = unsafe { &*PROCESS_TABLE.0.get() }; + table + .get(pid) + .and_then(|proc| syscalls::find_listener_fd_by_accept_wake(proc, wake_idx)) + .unwrap_or(-1) } /// Check if a file descriptor has O_NONBLOCK set. @@ -10126,7 +11011,7 @@ pub extern "C" fn kernel_pty_master_write(pty_idx: u32, buf_ptr: *const u8, buf_ let pids = table.pids_in_group(fg_pgid as u32); for pid in pids { if let Some(proc) = table.get_mut(pid) { - proc.signals.raise(signum); + proc.raise_signal(signum); } } } diff --git a/crates/shared/src/host_abi.rs b/crates/shared/src/host_abi.rs index f6549aff33..9801751bb9 100644 --- a/crates/shared/src/host_abi.rs +++ b/crates/shared/src/host_abi.rs @@ -8,7 +8,10 @@ use core::mem::size_of; use crate::abi::extended_syscalls as extra_syscalls; -use crate::{Syscall, WasmStat, WasmStatfs, WasmTimespec}; +use crate::{ + SCHED_AFFINITY_MASK_SIZE, Syscall, WASM_RUSAGE_WIRE_SIZE, WasmStat, WasmStatfs, + WasmTimespec, +}; /// Direction of a marshalled pointer argument. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -42,6 +45,10 @@ pub struct SyscallArgDesc { pub arg_index: u8, pub direction: SyscallArgDirection, pub size: SyscallArgSize, + /// Whether a null pointer is a valid request to omit this argument. + pub nullable: bool, + /// Whether a non-C-string pointer must be non-null. + pub required: bool, /// Extra bytes to copy back when an output `Arg`-sized buffer's copied /// length is based on the syscall return value. `msgrcv` returns only /// `mtext` length, but the scratch buffer also includes the leading mtype. @@ -105,6 +112,28 @@ macro_rules! desc { arg_index: $arg_index, direction: SyscallArgDirection::$direction, size: $size, + nullable: false, + required: false, + copy_retval_add: 0, + } + }; + ($arg_index:expr, $direction:ident, $size:expr, nullable) => { + SyscallArgDesc { + arg_index: $arg_index, + direction: SyscallArgDirection::$direction, + size: $size, + nullable: true, + required: false, + copy_retval_add: 0, + } + }; + ($arg_index:expr, $direction:ident, $size:expr, required) => { + SyscallArgDesc { + arg_index: $arg_index, + direction: SyscallArgDirection::$direction, + size: $size, + nullable: false, + required: true, copy_retval_add: 0, } }; @@ -113,6 +142,8 @@ macro_rules! desc { arg_index: $arg_index, direction: SyscallArgDirection::$direction, size: $size, + nullable: false, + required: false, copy_retval_add: $copy_retval_add, } }; @@ -288,12 +319,26 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ Syscall::Readlinkat as u32, [desc!(1, In, cstring!()), desc!(2, Out, arg!(3)),] ), - entry!(Syscall::Getrusage as u32, [desc!(1, Out, fixed!(144))]), + entry!( + Syscall::Getrusage as u32, + [desc!(1, Out, fixed!(WASM_RUSAGE_WIRE_SIZE), required)] + ), entry!( Syscall::Realpath as u32, [desc!(0, In, cstring!()), desc!(1, Out, arg!(2)),] ), entry!(Syscall::Sigsuspend as u32, [desc!(0, In, fixed!(8))]), + entry!( + Syscall::Pathconf as u32, + [ + desc!(0, In, cstring!()), + desc!(2, Out, fixed!(8), required), + ] + ), + entry!( + Syscall::Fpathconf as u32, + [desc!(2, Out, fixed!(8), required)] + ), entry!( Syscall::Getsockname as u32, [desc!(1, Out, deref!(2)), desc!(2, InOut, fixed!(4)),] @@ -316,7 +361,7 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!( Syscall::Utimensat as u32, [ - desc!(1, In, cstring!()), + desc!(1, In, cstring!(), nullable), desc!(2, In, fixed!(WASM_TIMESPEC_SIZE * 2)), ] ), @@ -351,7 +396,10 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ entry!(Syscall::Recvmsg as u32, [desc!(1, InOut, arg!(2))]), entry!( Syscall::Wait4 as u32, - [desc!(1, Out, fixed!(4)), desc!(3, Out, fixed!(32)),] + [ + desc!(1, Out, fixed!(4)), + desc!(3, Out, fixed!(WASM_RUSAGE_WIRE_SIZE)), + ] ), entry!( Syscall::Getaddrinfo as u32, @@ -404,6 +452,10 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ extra_syscalls::SYS_SCHED_RR_GET_INTERVAL, [desc!(1, Out, fixed!(WASM_TIMESPEC_SIZE))] ), + entry!( + extra_syscalls::SYS_SCHED_GETAFFINITY, + [desc!(2, Out, fixed!(SCHED_AFFINITY_MASK_SIZE), required)] + ), entry!( extra_syscalls::SYS_PRLIMIT64, [desc!(2, In, fixed!(16)), desc!(3, Out, fixed!(16)),] @@ -415,6 +467,14 @@ pub const SYSCALL_ARG_DESCRIPTORS: &[SyscallArgDescriptor] = &[ ), entry!(extra_syscalls::SYS_MKNOD, [desc!(0, In, cstring!())]), entry!(extra_syscalls::SYS_MKNODAT, [desc!(1, In, cstring!())]), + entry!( + extra_syscalls::SYS_WAITID, + [ + desc!(2, Out, fixed!(128), required), + desc!(4, Out, fixed!(WASM_RUSAGE_WIRE_SIZE), nullable), + ] + ), + entry!(extra_syscalls::SYS_LCHOWN, [desc!(0, In, cstring!())]), entry!( extra_syscalls::SYS_TIMER_CREATE, [desc!(1, In, fixed!(16)), desc!(2, Out, fixed!(4)),] @@ -509,6 +569,74 @@ mod tests { ); assert_eq!(msgrcv.copy_retval_add, 4); + let lchown = find(extra_syscalls::SYS_LCHOWN).args[0]; + assert_eq!(lchown.arg_index, 0); + assert_eq!(lchown.direction, SyscallArgDirection::In); + assert_eq!(lchown.size, SyscallArgSize::CString); + assert!(!lchown.nullable); + + let utimensat_path = find(Syscall::Utimensat as u32).args[0]; + assert_eq!(utimensat_path.size, SyscallArgSize::CString); + assert!(utimensat_path.nullable); + + let pathconf = find(Syscall::Pathconf as u32).args; + assert_eq!(pathconf[0].size, SyscallArgSize::CString); + assert!(!pathconf[0].nullable); + assert_eq!(pathconf[1].arg_index, 2); + assert_eq!(pathconf[1].direction, SyscallArgDirection::Out); + assert_eq!(pathconf[1].size, SyscallArgSize::Fixed { size: 8 }); + assert!(pathconf[1].required); + + let fpathconf = find(Syscall::Fpathconf as u32).args[0]; + assert_eq!(fpathconf.arg_index, 2); + assert_eq!(fpathconf.direction, SyscallArgDirection::Out); + assert_eq!(fpathconf.size, SyscallArgSize::Fixed { size: 8 }); + assert!(fpathconf.required); + + let getrusage = find(Syscall::Getrusage as u32).args[0]; + assert_eq!( + getrusage.size, + SyscallArgSize::Fixed { + size: WASM_RUSAGE_WIRE_SIZE, + } + ); + assert!(getrusage.required); + + let wait4 = find(Syscall::Wait4 as u32).args; + assert_eq!( + wait4[1].size, + SyscallArgSize::Fixed { + size: WASM_RUSAGE_WIRE_SIZE, + } + ); + + let waitid = find(extra_syscalls::SYS_WAITID).args; + assert_eq!(waitid[0].arg_index, 2); + assert_eq!(waitid[0].direction, SyscallArgDirection::Out); + assert_eq!(waitid[0].size, SyscallArgSize::Fixed { size: 128 }); + assert!(waitid[0].required); + assert_eq!(waitid[1].arg_index, 4); + assert_eq!(waitid[1].direction, SyscallArgDirection::Out); + assert_eq!( + waitid[1].size, + SyscallArgSize::Fixed { + size: WASM_RUSAGE_WIRE_SIZE, + } + ); + assert!(waitid[1].nullable); + + let sched_getaffinity = + find(extra_syscalls::SYS_SCHED_GETAFFINITY).args[0]; + assert_eq!(sched_getaffinity.arg_index, 2); + assert_eq!(sched_getaffinity.direction, SyscallArgDirection::Out); + assert_eq!( + sched_getaffinity.size, + SyscallArgSize::Fixed { + size: SCHED_AFFINITY_MASK_SIZE, + } + ); + assert!(sched_getaffinity.required); + let semop = find(extra_syscalls::SYS_SEMOP).args[0].size; assert_eq!( semop, diff --git a/crates/shared/src/lib.rs b/crates/shared/src/lib.rs index a16206d6b8..dadca0dde2 100644 --- a/crates/shared/src/lib.rs +++ b/crates/shared/src/lib.rs @@ -29,7 +29,58 @@ pub mod host_abi; /// 17: intentionally skipped during release coordination. /// 18: fork frame cursors are absolute save-buffer addresses, isolating /// concurrent pthread unwind payloads. -pub const ABI_VERSION: u32 = 18; +/// 19: bridged TCP EPIPE delivery raises SIGPIPE unless the caller suppresses +/// it, matching the signal contract of local stream writes. +/// 20: mremap rejects unsupported flag bits instead of silently accepting +/// them under the existing syscall number. +/// 21: missing, PID-zero, and reaped procfs paths report ENOENT instead of +/// returning synthetic success through stat/access/path operations. +/// 22: socket addresses, option pointers, accepted descriptors, multicast, +/// routing, hostname errors, and inherited socket state are reconciled. +/// 23: TCP FIN, EOF, EPIPE, wakeup, bridge-reader, and queued-data behavior +/// use the required real-reader kernel export. +/// 24: datagram admission, Unix EAGAIN, blocking, readiness, wakeups, and +/// finite deadlines expose queue pressure consistently. +/// 25: broadcast errors, MSG_TRUNC results, and zero-length output pointers +/// follow the reconciled datagram contract. +/// 26: wait/signal, mmap, exec, descriptor/socket/shared-memory inheritance, +/// fork metadata, epoll, sleep, and forced-removal state are preserved. +/// 27: rebuilt PHP programs require the cooperative VM-interrupt host hook, +/// and POSIX timer notifications use validated fixed-width signal fields. +/// 28: host path, clock/timer, exec/spawn, worker, and persistent-VFS behavior +/// is aligned across Node and browser runtime boundaries. +/// 29: cancellation-point function types, 64-bit argument slots, lseek state, +/// and select/pselect signal interruption are reconciled. +/// 30: launch environments and unchanged-ID chown operations preserve their +/// process, authorization, and backend-error semantics. +/// 31: operation-wide file-size preflight and exact-thread SIGXFSZ delivery +/// are required for write-family operations. +/// 32: component-wise path, symlink, mount, errno, and current-directory +/// resolution use one canonical namespace contract. +/// 33: no-follow symlink ownership requires the lchown host surface and its +/// descriptor/flag behavior. +/// 34: pathconf uses 64-bit result storage, live-object queries, and the +/// required host marshalling surface. +/// 35: wait/status/rusage, WNOWAIT, stop/continue, blocking, and pthread-exit +/// lifecycle use the reconciled process wire contract. +/// 36: side-module replay-control memory and concurrent pthread-fork +/// arbitration share one host/guest continuation contract. +/// 37: pending host-delegated AF_INET stream connects expose EINPROGRESS then +/// EALREADY to non-blocking callers while blocking callers wait for the +/// same host handshake to complete or fail. +/// 38: sched_getaffinity marshals its fixed kernel mask across process memory, +/// validates live task identity, and exposes the Linux raw return contract. +/// 39: kernel-owned POSIX timer expiration preserves exact thread targets, +/// SI_TIMER metadata, overruns, and finite signal-wait deadlines through +/// the required host timer-fire export. +pub const ABI_VERSION: u32 = 39; + +/// Byte width of Kandelo's Linux-compatible kernel CPU-affinity mask. +/// +/// The current kernel models one CPU and uses one wasm32 kernel word. Both +/// wasm32 and wasm64 guests therefore receive the same four-byte raw mask; +/// changing this width is an ABI change. +pub const SCHED_AFFINITY_MASK_SIZE: u32 = 4; /// Syscall numbers for the POSIX kernel interface. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -320,6 +371,65 @@ impl Syscall { } } +/// ABI-visible names accepted by `pathconf()` and `fpathconf()`. +/// +/// These values are consumed by libc, the kernel, and the generated host +/// bindings. Keep the numeric contract centralized here rather than copying +/// the `_PC_*` numbering into each layer. +pub mod pathconf { + pub const LINK_MAX: i32 = 0; + pub const MAX_CANON: i32 = 1; + pub const MAX_INPUT: i32 = 2; + pub const NAME_MAX: i32 = 3; + pub const PATH_MAX: i32 = 4; + pub const PIPE_BUF: i32 = 5; + pub const CHOWN_RESTRICTED: i32 = 6; + pub const NO_TRUNC: i32 = 7; + pub const VDISABLE: i32 = 8; + pub const SYNC_IO: i32 = 9; + pub const ASYNC_IO: i32 = 10; + pub const PRIO_IO: i32 = 11; + pub const SOCK_MAXBUF: i32 = 12; + pub const FILESIZEBITS: i32 = 13; + pub const REC_INCR_XFER_SIZE: i32 = 14; + pub const REC_MAX_XFER_SIZE: i32 = 15; + pub const REC_MIN_XFER_SIZE: i32 = 16; + pub const REC_XFER_ALIGN: i32 = 17; + pub const ALLOC_SIZE_MIN: i32 = 18; + pub const SYMLINK_MAX: i32 = 19; + pub const POSIX2_SYMLINKS: i32 = 20; + pub const FALLOC: i32 = 21; + pub const TEXTDOMAIN_MAX: i32 = 22; + pub const TIMESTAMP_RESOLUTION: i32 = 23; + + pub const ABI_NAMES: &[(&str, i32)] = &[ + ("LINK_MAX", LINK_MAX), + ("MAX_CANON", MAX_CANON), + ("MAX_INPUT", MAX_INPUT), + ("NAME_MAX", NAME_MAX), + ("PATH_MAX", PATH_MAX), + ("PIPE_BUF", PIPE_BUF), + ("CHOWN_RESTRICTED", CHOWN_RESTRICTED), + ("NO_TRUNC", NO_TRUNC), + ("VDISABLE", VDISABLE), + ("SYNC_IO", SYNC_IO), + ("ASYNC_IO", ASYNC_IO), + ("PRIO_IO", PRIO_IO), + ("SOCK_MAXBUF", SOCK_MAXBUF), + ("FILESIZEBITS", FILESIZEBITS), + ("REC_INCR_XFER_SIZE", REC_INCR_XFER_SIZE), + ("REC_MAX_XFER_SIZE", REC_MAX_XFER_SIZE), + ("REC_MIN_XFER_SIZE", REC_MIN_XFER_SIZE), + ("REC_XFER_ALIGN", REC_XFER_ALIGN), + ("ALLOC_SIZE_MIN", ALLOC_SIZE_MIN), + ("SYMLINK_MAX", SYMLINK_MAX), + ("POSIX2_SYMLINKS", POSIX2_SYMLINKS), + ("FALLOC", FALLOC), + ("TEXTDOMAIN_MAX", TEXTDOMAIN_MAX), + ("TIMESTAMP_RESOLUTION", TIMESTAMP_RESOLUTION), + ]; +} + /// Status of the shared-memory syscall channel. #[derive(Debug, Clone, Copy, PartialEq, Eq)] #[repr(u32)] @@ -363,6 +473,7 @@ pub enum Errno { EBUSY = 16, EEXIST = 17, EXDEV = 18, + ENODEV = 19, ENOTDIR = 20, EISDIR = 21, EINVAL = 22, @@ -427,6 +538,7 @@ impl Errno { 16 => Some(Errno::EBUSY), 17 => Some(Errno::EEXIST), 18 => Some(Errno::EXDEV), + 19 => Some(Errno::ENODEV), 20 => Some(Errno::ENOTDIR), 21 => Some(Errno::EISDIR), 22 => Some(Errno::EINVAL), @@ -565,6 +677,7 @@ pub mod socket { pub const SOCK_CLOEXEC: u32 = 0o2000000; pub const SOL_SOCKET: u32 = 1; pub const SCM_RIGHTS: u32 = 1; + pub const SCM_CREDENTIALS: u32 = 2; pub const SO_REUSEADDR: u32 = 2; pub const SO_ERROR: u32 = 4; pub const SO_KEEPALIVE: u32 = 9; @@ -573,15 +686,49 @@ pub mod socket { pub const SO_TYPE: u32 = 3; pub const SO_DOMAIN: u32 = 39; pub const SO_ACCEPTCONN: u32 = 30; + pub const SO_REUSEPORT: u32 = 15; + pub const SO_PASSCRED: u32 = 16; pub const SHUT_RD: u32 = 0; pub const SHUT_WR: u32 = 1; pub const SHUT_RDWR: u32 = 2; pub const SO_BROADCAST: u32 = 6; pub const SO_LINGER: u32 = 13; + pub const SO_BINDTODEVICE: u32 = 25; + pub const SO_ATTACH_REUSEPORT_CBPF: u32 = 51; + pub const SO_ZEROCOPY: u32 = 60; + // Traditional Linux numbers used when `long` is 64 bits (including + // wasm64). The time64 aliases below are used when `long` is 32 bits. + pub const SO_RCVTIMEO_OLD: u32 = 20; + pub const SO_SNDTIMEO_OLD: u32 = 21; // time64 values used by musl on wasm32 (where __LONG_MAX == 0x7fffffff) pub const SO_RCVTIMEO: u32 = 66; pub const SO_SNDTIMEO: u32 = 67; + pub const IPPROTO_IP: u32 = 0; pub const IPPROTO_TCP: u32 = 6; + pub const IPPROTO_UDP: u32 = 17; + pub const IPPROTO_IPV6: u32 = 41; + pub const IP_TOS: u32 = 1; + pub const IP_PKTINFO: u32 = 8; + pub const IP_MTU_DISCOVER: u32 = 10; + pub const IP_MTU: u32 = 14; + pub const IP_MULTICAST_IF: u32 = 32; + pub const IP_MULTICAST_TTL: u32 = 33; + pub const IP_MULTICAST_LOOP: u32 = 34; + pub const IP_ADD_MEMBERSHIP: u32 = 35; + pub const IP_DROP_MEMBERSHIP: u32 = 36; + pub const IP_UNBLOCK_SOURCE: u32 = 37; + pub const IP_BLOCK_SOURCE: u32 = 38; + pub const IP_ADD_SOURCE_MEMBERSHIP: u32 = 39; + pub const IP_DROP_SOURCE_MEMBERSHIP: u32 = 40; + pub const IP_MSFILTER: u32 = 41; + pub const MCAST_JOIN_GROUP: u32 = 42; + pub const MCAST_BLOCK_SOURCE: u32 = 43; + pub const MCAST_UNBLOCK_SOURCE: u32 = 44; + pub const MCAST_LEAVE_GROUP: u32 = 45; + pub const MCAST_JOIN_SOURCE_GROUP: u32 = 46; + pub const MCAST_LEAVE_SOURCE_GROUP: u32 = 47; + pub const MCAST_MSFILTER: u32 = 48; + pub const IP_MULTICAST_ALL: u32 = 49; pub const TCP_NODELAY: u32 = 1; pub const TCP_CORK: u32 = 3; pub const TCP_KEEPIDLE: u32 = 4; @@ -590,9 +737,20 @@ pub mod socket { pub const TCP_DEFER_ACCEPT: u32 = 9; pub const TCP_INFO: u32 = 11; pub const TCP_QUICKACK: u32 = 12; + pub const TCP_CONGESTION: u32 = 13; pub const TCP_USER_TIMEOUT: u32 = 18; + pub const IPV6_MULTICAST_IF: u32 = 17; + pub const IPV6_MULTICAST_HOPS: u32 = 18; + pub const IPV6_MULTICAST_LOOP: u32 = 19; + pub const IPV6_V6ONLY: u32 = 26; + pub const IPV6_RECVPKTINFO: u32 = 49; + pub const IPV6_PKTINFO: u32 = 50; + pub const IPV6_DONTFRAG: u32 = 62; + pub const IPV6_RECVTCLASS: u32 = 66; + pub const IPV6_TCLASS: u32 = 67; pub const MSG_OOB: u32 = 1; pub const MSG_PEEK: u32 = 2; + pub const MSG_TRUNC: u32 = 0x20; pub const MSG_DONTWAIT: u32 = 64; pub const MSG_NOSIGNAL: u32 = 0x4000; } @@ -791,6 +949,8 @@ pub mod signal { pub const SIGCONT: u32 = 18; pub const SIGSTOP: u32 = 19; pub const SIGTSTP: u32 = 20; + pub const SIGTTIN: u32 = 21; + pub const SIGTTOU: u32 = 22; pub const SIGXCPU: u32 = 24; pub const SIGXFSZ: u32 = 25; pub const SIGWINCH: u32 = 28; @@ -819,8 +979,8 @@ pub mod signal { pub const SA_DEFAULT_TERM: u32 = 0; // Terminate pub const SA_DEFAULT_IGN: u32 = 1; // Ignore pub const SA_DEFAULT_CORE: u32 = 2; // Core dump (treated as terminate in Wasm) - pub const SA_DEFAULT_STOP: u32 = 3; // Stop (not supported in Wasm) - pub const SA_DEFAULT_CONT: u32 = 4; // Continue (not supported in Wasm) + pub const SA_DEFAULT_STOP: u32 = 3; // Stop until a continue transition + pub const SA_DEFAULT_CONT: u32 = 4; // Continue a stopped process } /// Resource limit constants for getrlimit/setrlimit. @@ -843,6 +1003,110 @@ pub mod rusage { pub const RUSAGE_CHILDREN: i32 = -1; } +/// Process-wait event, option, result, and host-wakeup constants. +pub mod wait { + /// A child exit event is eligible for selection. + pub const EVENT_EXITED: u32 = 1; + /// A child stop event is eligible for selection. + pub const EVENT_STOPPED: u32 = 2; + /// A child continue event is eligible for selection. + pub const EVENT_CONTINUED: u32 = 4; + + pub const WNOHANG: u32 = 1; + pub const WUNTRACED: u32 = 2; + pub const WSTOPPED: u32 = WUNTRACED; + pub const WEXITED: u32 = 4; + pub const WCONTINUED: u32 = 8; + pub const WNOWAIT: u32 = 0x0100_0000; + + pub const CLD_EXITED: i32 = 1; + pub const CLD_KILLED: i32 = 2; + pub const CLD_STOPPED: i32 = 5; + pub const CLD_CONTINUED: i32 = 6; + + pub const PROCESS_STATE_RUNNING: i32 = 0; + pub const PROCESS_STATE_STOPPED: i32 = 1; + pub const PROCESS_STATE_EXITED: i32 = 2; + + /// Host retry wake reason: the process entered a stopped state. + pub const WAKE_PROCESS_STOPPED: u8 = 16; + /// Host retry wake reason: the process resumed from a stopped state. + pub const WAKE_PROCESS_CONTINUED: u8 = 32; +} + +/// Fixed-width kernel/musl resource-usage wire record. +/// +/// This is not musl's public `struct rusage`, whose size depends on the +/// target's `long` width. Both wasm32 and wasm64 exchange the meaningful +/// prefix as eighteen little-endian 64-bit slots. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(C)] +pub struct WasmRusageWire { + pub ru_utime_sec: i64, + pub ru_utime_usec: i64, + pub ru_stime_sec: i64, + pub ru_stime_usec: i64, + pub ru_maxrss: i64, + pub ru_ixrss: i64, + pub ru_idrss: i64, + pub ru_isrss: i64, + pub ru_minflt: i64, + pub ru_majflt: i64, + pub ru_nswap: i64, + pub ru_inblock: i64, + pub ru_oublock: i64, + pub ru_msgsnd: i64, + pub ru_msgrcv: i64, + pub ru_nsignals: i64, + pub ru_nvcsw: i64, + pub ru_nivcsw: i64, +} + +/// Compatibility name used by kernel-side wait/resource-usage code. +pub type KernelRusage = WasmRusageWire; + +pub const WASM_RUSAGE_WIRE_SIZE: u32 = core::mem::size_of::() as u32; + +/// Fixed result record written by `kernel_wait_child_poll`. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +#[repr(C)] +pub struct KernelWaitResult { + pub wait_status: i32, + pub si_code: i32, + pub si_status: i32, + pub child_uid: u32, + pub rusage: WasmRusageWire, +} + +pub const KERNEL_WAIT_RESULT_SIZE: u32 = core::mem::size_of::() as u32; + +#[cfg(test)] +mod wait_abi_tests { + use super::{KERNEL_WAIT_RESULT_SIZE, KernelWaitResult, WASM_RUSAGE_WIRE_SIZE, WasmRusageWire}; + use core::mem::{offset_of, size_of}; + + #[test] + fn rusage_wire_layout_is_eighteen_i64_slots() { + assert_eq!(WASM_RUSAGE_WIRE_SIZE, 144); + assert_eq!(size_of::(), 144); + assert_eq!(offset_of!(WasmRusageWire, ru_utime_sec), 0); + assert_eq!(offset_of!(WasmRusageWire, ru_stime_sec), 16); + assert_eq!(offset_of!(WasmRusageWire, ru_maxrss), 32); + assert_eq!(offset_of!(WasmRusageWire, ru_nivcsw), 136); + } + + #[test] + fn kernel_wait_result_layout_is_stable() { + assert_eq!(KERNEL_WAIT_RESULT_SIZE, 160); + assert_eq!(size_of::(), 160); + assert_eq!(offset_of!(KernelWaitResult, wait_status), 0); + assert_eq!(offset_of!(KernelWaitResult, si_code), 4); + assert_eq!(offset_of!(KernelWaitResult, si_status), 8); + assert_eq!(offset_of!(KernelWaitResult, child_uid), 12); + assert_eq!(offset_of!(KernelWaitResult, rusage), 16); + } +} + /// select() constants. pub mod select { pub const FD_SETSIZE: usize = 1024; @@ -856,6 +1120,9 @@ pub mod clock { pub const CLOCK_MONOTONIC: u32 = 1; pub const CLOCK_PROCESS_CPUTIME_ID: u32 = 2; pub const CLOCK_THREAD_CPUTIME_ID: u32 = 3; + pub const CLOCK_REALTIME_COARSE: u32 = 5; + pub const CLOCK_MONOTONIC_COARSE: u32 = 6; + pub const CLOCK_BOOTTIME: u32 = 7; } /// Timespec structure for the Wasm POSIX interface. @@ -1096,13 +1363,19 @@ pub mod abi { "kernel_create_process", "kernel_create_process_with_stdio", "kernel_get_parent_pid", + "kernel_get_process_exit_signal", + "kernel_get_process_state", "kernel_handle_channel", + "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", "kernel_mark_process_signaled", + "kernel_pipe_has_readers", + "kernel_posix_timer_fire", + "kernel_prepare_write_operation", "kernel_reap_exited_child", "kernel_remove_process", - "kernel_wait4_poll", + "kernel_wait_child_poll", ]; pub const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS: &[&str] = &[ @@ -1167,6 +1440,7 @@ pub mod abi { pub const SYS_SCHED_YIELD: u32 = 229; pub const SYS_SCHED_GETPARAM: u32 = 230; pub const SYS_SCHED_RR_GET_INTERVAL: u32 = 236; + pub const SYS_SCHED_GETAFFINITY: u32 = 238; pub const SYS_EPOLL_CREATE1: u32 = 239; pub const SYS_EPOLL_CTL: u32 = 240; pub const SYS_EPOLL_PWAIT: u32 = 241; @@ -1183,6 +1457,7 @@ pub mod abi { pub const SYS_SENDFILE: u32 = 294; pub const SYS_PREADV: u32 = 295; pub const SYS_PWRITEV: u32 = 296; + pub const SYS_LCHOWN: u32 = 299; pub const SYS_FALLOCATE: u32 = 308; pub const SYS_TIMER_CREATE: u32 = 326; pub const SYS_TIMER_SETTIME: u32 = 327; @@ -1303,6 +1578,10 @@ pub mod abi { name: "SchedRrGetInterval", number: SYS_SCHED_RR_GET_INTERVAL, }, + AbiSyscallNumber { + name: "SchedGetaffinity", + number: SYS_SCHED_GETAFFINITY, + }, AbiSyscallNumber { name: "EpollCreate1", number: SYS_EPOLL_CREATE1, @@ -1367,6 +1646,10 @@ pub mod abi { name: "Pwritev", number: SYS_PWRITEV, }, + AbiSyscallNumber { + name: "Lchown", + number: SYS_LCHOWN, + }, AbiSyscallNumber { name: "Fallocate", number: SYS_FALLOCATE, diff --git a/docs/abi-versioning.md b/docs/abi-versioning.md index 48c5b6c803..00dce363e3 100644 --- a/docs/abi-versioning.md +++ b/docs/abi-versioning.md @@ -24,7 +24,8 @@ kernel. Specifically, any of the following requires an `ABI_VERSION` bump: - Removing, renaming, or reassigning a syscall number. - Changing an existing syscall argument descriptor used by the host for pointer marshalling, including direction, size source, multipliers, - fixed byte lengths, or return-value copy adjustments. + fixed byte lengths, pointer nullability/requiredness, or return-value copy + adjustments. - Changing the channel header layout (field offsets or sizes in [`crates/shared/src/lib.rs`](../crates/shared/src/lib.rs) `channel` module). @@ -39,11 +40,42 @@ 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. +- Adding or changing a required kernel-Wasm host import. Kernel imports are not + yet present in the structural snapshot, so reviewers must track this surface + explicitly and coordinate the host implementation in the same ABI epoch. +- 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. - Renaming the ABI custom section or the process-expected globals. - Changing the meaning of a syscall argument, errno, or blocking behavior without changing its signature. **This is not caught structurally — reviewers must flag it and bump anyway.** +The fork-capability section has an explicit ABI transition rule. ABI 16 accepts +an absent section through the pre-existing five-export fallback, while treating +a present marker as authoritative. ABI 17 was intentionally skipped; ABI 18 +was the first epoch above 16 and made the role marker mandatory. + +ABI 26 also makes `kernel_get_process_exit_signal` a required host-adapter +export. The host uses the query unconditionally to distinguish signal death +from ordinary high exit statuses, so a kernel without it must fail manifest +validation rather than silently treating the process as live. + +ABI 31 makes `kernel_prepare_write_operation` required. Host-backed writes use +that preflight unconditionally before splitting one guest operation into +scratch-buffer chunks, so a kernel without it must fail manifest validation +rather than bypassing operation-wide file-size enforcement. + +ABI 39 makes `kernel_posix_timer_fire` required. The host uses it for every +host-scheduled POSIX timer expiration so the kernel can preserve exact +`SIGEV_THREAD_ID` targets, `SI_TIMER` metadata, overruns, and signal-wait wake +selection. A kernel without it must fail manifest validation rather than fall +back to process-wide delivery. + Pure internal refactors (renaming a kernel-side function, reorganizing a source file, tightening a bound in a non-ABI type) are *not* ABI changes and do not require a bump. @@ -93,7 +125,10 @@ captures: the core enum. - `syscall_arg_descriptors` — host marshalling descriptors for pointer arguments, including direction, size source, size multipliers/additions, - fixed byte lengths, and any return-value-based copy-back adjustment. + fixed byte lengths, pointer nullability/requiredness, and any + return-value-based copy-back adjustment. +- `pathconf_names` — the shared numeric `_PC_*` vocabulary consumed by the + kernel, generated host bindings, and libc wrappers. - `host_adapter` — Rust-owned boot manifest metadata consumed by host adapters: manifest layout, host adapter protocol version, required worker feature bits, and required/optional kernel exports. diff --git a/docs/architecture.md b/docs/architecture.md index 5599e6b487..2441010bb0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -78,6 +78,14 @@ kernel_set_mmap_base(pid, addr) → 0 kernel_is_fd_nonblock(pid, fd) → bool ``` +Normal guest exit closes descriptors before the process becomes reapable. +When the host instead removes a live process after explicit termination or a +worker failure, `kernel_remove_process` drops its inherited resource references +and closes its last-owned host file, directory-iteration, and network handles. +Failed spawn setup drains the same close lists during rollback. Node.js and +browser hosts use the common removal path, so forced termination does not +retain those backend descriptors after the worker is gone. + Host imports (provided by TypeScript): ``` @@ -123,6 +131,37 @@ Key host components: | NodeWorkerAdapter | `worker-adapter.ts` | Creates Node.js worker_threads | | BrowserWorkerAdapter | `worker-adapter-browser.ts` | Creates Web Workers | +### Cooperative process-runtime interrupts + +A process module may import +`env.__wasm_posix_vm_interrupt_after(timed_out_ptr, vm_interrupt_ptr, seconds)` +to request a cooperative runtime deadline. This import is intended for +language runtimes that already poll an interrupt flag at VM safepoints; it is +not a substitute for POSIX signal delivery or a general program-specific +kernel hook. + +The process worker forwards each arm or cancellation request to its host's +dedicated kernel worker. The kernel worker owns the JavaScript timer because a +process worker executing a CPU-bound Wasm loop cannot service its own event +loop. At the monotonic deadline the host sets each flag byte with an atomic +store in the process's shared memory. Timer entries retain the exact +process-generation object as well as the PID, so exec, exit, or PID reuse +cannot redirect a stale callback into a replacement process. Deadlines beyond +JavaScript's signed 32-bit timer range are scheduled in bounded chunks. + +The imported function and its pointer-width-specific signature are part of +the guest/host ABI. A package that starts importing it must raise its kernel +ABI floor in the same ABI reconciliation that versions and snapshots the new +surface. + +Host-runtime lifecycle diagnostics are kept separate from guest file +descriptor 2. Both kernel-worker entries send typed `host_diagnostic` messages; +`NodeKernelHost` and `BrowserKernel` expose them through `onHostDiagnostic`. +Node also records them on the embedding process's console, while the browser +live-demo consumer records them in dmesg. Neither path appends host diagnostics +to the program's stderr byte stream, so test harnesses and applications observe +only bytes the guest actually wrote. + ### 3. Glue Layer (C) **Location**: `libc/glue/` @@ -145,18 +184,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 @@ -212,7 +253,9 @@ Fork uses the in-tree `wasm-fork-instrument` tool to snapshot the Wasm call stac 2. Host's `kernel_fork` override calls `wpk_fork_unwind_begin(buf)`. The tool-injected export sets state to UNWINDING, initializes the absolute frame cursor `current_pos = buf + frames_start_offset` at `*(buf+0)`, and snapshots every mutable scalar global (including `__tls_base` and `__stack_pointer`) into the buffer's `saved_globals[]` area. 3. The return-to-caller chain unwinds; each instrumented function's postamble writes its frame to the buffer and bumps `current_pos`. 4. Once `_start` returns (top-of-stack), the host sends SYS_FORK through the channel. -5. Kernel's `kernel_fork_process` copies fd table, signals, env, CWD, etc. +5. Kernel's `kernel_fork_process` copies process metadata and the fd/OFD tables, + while inherited stateful descriptors retain references to their existing + kernel-global backings. 6. Host copies the parent's linear memory to a new `WebAssembly.Memory` and spawns a child worker. 7. Child worker calls `wpk_fork_rewind_begin(buf)` — the tool's export restores all saved globals. The host then calls `setupChannelBase(...)` (which reads the now-correct `__tls_base`) and invokes `_start`. 8. Each instrumented function's preamble sees state=REWINDING, reloads its frame, and re-enters the call site where the parent was interrupted. Eventually reaches the `kernel_fork` call site in the leaf function, which returns 0. @@ -220,13 +263,73 @@ Fork uses the in-tree `wasm-fork-instrument` tool to snapshot the Wasm call stac The instrumentation handles LLVM's new-EH `try_table` output correctly, including fork from inside C++ catch handlers. See [fork-instrumentation.md](fork-instrumentation.md) for the current guarantees and documented unanticipated Wasm-level carve-outs. +A fork reached directly inside an instrumented dlopened side module uses two +ordered state machines and two save buffers: side then main during unwind, main +then side during rewind. Versioned fork-instrument capability metadata lets +marker-present artifacts prove their role. ABI 16 defines the historical +five-export fallback, while ABI 18 and later require role claims and reject +stale call-graph artifacts. The ABI 36 epoch combines that contract with +side-module replay state and concurrent pthread-fork arbitration. Dlopen replay +records both the parent's memory base and exact table base, including null gaps +left by failed loads. TLS-bearing side modules additionally record their live, +positive `__tls_base`. A child restores the pointer-width-correct mutable +global without calling `__wasm_init_tls`, because copied memory already holds +the parent's live TLS bytes and reinitialization would reset C++ unwinder state +and application `thread_local` values. C++ exceptions and longjmp use one +canonical pointer-width tag identity across the main image and all side +modules; a main-exported tag wins over the host-created fallback. + +The dlopen replay list and its atomic pthread-fork lock live in a transient, +host-private control record. The same host build writes and reads that record +during one process lifetime; guest code and persisted artifacts never +interpret it. Changing that record's size is therefore not a guest ABI change, +while the public ABI snapshot/classifier remains authoritative. + +Pthread workers have separate Wasm instances, tables, and exception tags, none +of which can be structured-cloned from the process worker. `dlopen()` from a +pthread therefore fails normally with `dlerror()`. If the process has loaded a +side module, `fork()` from a pthread returns `ENOTSUP`; an atomic process lock +excludes a racing main-worker dlopen across the pthread's archive check, +unwind, memory copy, and parent rewind. The supported +direct-main-to-side boundary and the remaining opaque cross-side callback +limitation are specified in +[fork-instrumentation.md](fork-instrumentation.md#fork-from-a-dlopened-side-module). + +Fork and non-forking spawn still copy each process's fd and OFD metadata. The +objects whose mutable state must remain identical across those copies use +refcounted kernel-global backings: eventfd counters, timerfd timers, signalfd +masks, memfd contents and cursors, and procfs snapshots and cursors. Pipes, +sockets, PTYs, terminal devices, and listener queues likewise retain their +existing global object identity. Ordinary regular-file OFD metadata, including +the seek position and status flags, is still copied rather than shared; that +remaining POSIX gap is tracked in [posix-status.md](posix-status.md) and +[future-improvements.md](future-improvements.md). + ### exec() 1. User calls `execve(path, argv, envp)` → kernel returns exec request to host 2. Host resolves `path` to a Wasm binary (via filesystem or program map) -3. `kernel_exec_setup` closes CLOEXEC fds, resets signals, and **resets the program break** (POSIX/Linux behavior — the prior program's brk does not carry over) -4. Host terminates the old worker -5. Host creates fresh `WebAssembly.Memory` and re-registers the PID +3. The host compiles the replacement module, checks its ABI marker, and preallocates its fresh `WebAssembly.Memory` before the irreversible transition. It also validates a 4 MiB combined argv/environment representation (UTF-8 strings, NUL terminators, and caller-width pointer entries, with each string limited to one 64 KiB scratch transfer); oversized metadata returns `E2BIG` to the old image. After commit, argv and environment entries cross into the kernel one at a time, so the fixed host scratch allocation is never overrun and an empty environment explicitly clears the prior one. +4. The host validates the exec caller and deferred file actions, then publishes + and flushes writable tracked mappings while the old image is still live. + Tracked shared file mappings hold a lifetime-stable host handle independent + of the guest fd, so closing the original fd does not by itself prevent + writeback. A failed flush leaves the old mapping trackers and SysV + attachments in place. + `kernel_exec_setup` then closes CLOEXEC fds and directory streams and resets + image-specific state **in place**, including the program break (POSIX/Linux + behavior — the prior program's brk does not carry over). Exact kernel objects + behind surviving descriptors are never fork-cloned or reconstructed: socket + queues, eventfd/epoll/timerfd/signalfd state, memfd contents, procfs + snapshots, terminal input, and OFD identity therefore survive without + refcount churn. After that commit the host forgets the old mapping trackers + and detaches SysV segments. The calling pthread's signal mask and directed + queue become the process state; sibling workers terminate. + `alarm()`/`ITIMER_REAL` survives, while `timer_create()` timers are deleted. + The conformance gaps in [posix-status.md](posix-status.md) still apply, + notably numeric-fd epoll tracking and main-thread-directed signal + attribution. +5. Host terminates the old process and sibling-thread workers, then re-registers the PID with the preallocated memory 6. Host parses the new binary's `__heap_base` export and calls `kernel_set_brk_base(pid, __heap_base)` so `brk(0)` returns a value above the new program's data + stack region 7. Host spawns a new worker with the new program binary 8. New program starts from `_start` with the given argv/envp @@ -264,9 +367,11 @@ caller now take. memory. The host's `onSpawn` callback (Node: `host/src/node-kernel-worker-entry.ts::handlePosixSpawn`; Browser: `host/src/browser-kernel-worker-entry.ts::handlePosixSpawn`) - resolves the program bytes and instantiates a fresh Worker for the - child, registered with `skipKernelCreate: true` because the kernel - already inserted the Process. + receives the authoritative parent pid, resolves the program bytes, + instantiates a fresh Worker for the child, and publishes a parented + `proc_event` spawn notification. The Worker is registered with + `skipKernelCreate: true` because the kernel already inserted the + Process; its initialization metadata carries the same parent pid. PATH search lives in libc (`posix_spawnp.c`); the kernel never sees PATH-relative names. @@ -341,7 +446,44 @@ The Rust ABI declaration in `crates/shared/src/lib.rs` is the source of truth fo Processes may export `__wasm_posix_thread_slots` to declare their maximum concurrent pthread count. A value of `-1` uses the host default, `0` allows no pthreads, and a positive value sets the exact per-process limit. The kernel worker creation options expose `defaultThreadSlots` for the `-1`/missing-export case. The built-in default is 1024: an intentionally arbitrary high limit meant to avoid pthread availability problems for most programs now that slots are reserved on demand. Hosts can lower or raise it with `defaultThreadSlots` when they need a different resource policy. This limit is a resource-control guard, not a static memory reservation. -`mmap` remains coherent because the kernel has one per-process address-space model for brk, mmap, and host-reserved dynamic control ranges. Automatic `mmap` starts at the process's `mmap_base`, not at the legacy fixed 64MB floor. `brk` growth succeeds only when the adjacent range is free; if an mmap region or host-reserved pthread slot occupies the next pages, `brk` fails by returning the old break. `MAP_FIXED`, `munmap`, and `mremap` growth are rejected when they would overlap the reserved prefix, legacy host-control range, or a host-reserved pthread slot. The host grows the process `WebAssembly.Memory` after successful brk/mmap/mremap syscalls and after dynamic pthread-slot reservations so returned guest addresses are backed before user code touches them. +`mmap` remains coherent because the kernel has one per-process address-space model for brk, mmap, and host-reserved dynamic control ranges. Automatic `mmap` starts at the process's `mmap_base`, not at the legacy fixed 64MB floor. A usable non-fixed address hint is preferred after rounding it down to the 64KB Wasm page boundary; an occupied or invalid hint falls back to the ordinary first-fit search. `brk` growth succeeds only when the adjacent range is free; if an mmap region or host-reserved pthread slot occupies the next pages, `brk` fails by returning the old break. `MAP_FIXED`, `munmap`, and `mremap` growth are rejected when they would overlap the reserved prefix, legacy host-control range, or a host-reserved pthread slot. `munmap` rounds its length up to a Wasm page before updating both kernel mappings and host-owned bindings. The host grows the process `WebAssembly.Memory` after successful brk/mmap/mremap syscalls and after dynamic pthread-slot reservations so returned guest addresses are backed before user code touches them. + +### Shared mapping coherence + +Different processes have different WebAssembly memories, so a pointer store in +one process cannot immediately mutate another process's linear memory. Kandelo +coordinates anonymous `MAP_SHARED`, SysV SHM attachments, and regular-file +`MAP_SHARED` mappings at guest-to-kernel syscall boundaries. For each mapping, +the host compares process memory with the snapshot that process last observed, +merges only changed byte runs into one authoritative backing, and then imports +peer updates into every stale alias in the calling process. Fork force-publishes +the parent before the child inherits the same backing; `exec`, exit, crash, +`munmap`, `mremap`, and `MAP_FIXED` update backing ownership explicitly. + +Regular-file mappings add a backend-qualified stable identity and retain the +original fd's host handle for the mapping lifetime. Identity is derived and +revalidated through that live handle, never by reopening its remembered path. +Node uses native device/inode identity; VFS backends scope device/inode identity +to the handle's backend object, so hard links and the same backend mounted at +more than one path alias correctly without colliding with a different backend. +Dirty mapped data is published before +direct file reads or writes and before a private mapping takes its snapshot; +successful direct writes, truncation, allocation, splice, and copy operations +invalidate or refresh mapped cache pages. `msync`, replacement, unmap, exec, +and process teardown persist dirty pages through the stable handle, including +after the original guest fd is closed or its pathname is unlinked or renamed. + +This is syscall-boundary coherence, not shared physical memory. A process that +only performs direct loads/stores does not publish or import peer changes until +it crosses into the kernel. Futex waits and wakes also target the caller's own +process `SharedArrayBuffer`, so process-shared pthread mutexes/futexes remain +unsupported across PIDs. Shared mappings of in-kernel memfds return `ENOTSUP`, +as do file mappings on a backend that cannot provide stable identity (currently +OPFS reports zero inode identity); `MAP_PRIVATE` is unaffected. File bytes past +the current EOF are zero-filled or discarded on refresh/writeback rather than +raising Linux's `SIGBUS`, and writes made outside Kandelo's file syscall paths +are not detected. The boundary scans are on the syscall hot path; no performance +claim is made without before/after Node and browser benchmarks. Every spawn or exec computes a fresh layout from the target binary's memory import and `__heap_base`; the layout is per-process and is discarded when the process is unregistered. Fork children copy the parent's current memory length, not the configured maximum, and pthread workers share the owning process memory plus that process's thread allocator. WebAssembly memory cannot shrink, so a fork child may inherit the parent's current byte length, but it does not inherit dead parent pthread slot reservations. Correctness must not depend on page reloads, context resets, periodic kernel resets, or browser garbage collection reclaiming old shared memories. @@ -395,19 +537,25 @@ The browser host layers two additional, host-specific mounts on top: `/dev/shm` ### rootfs image as the source of truth -`/etc/passwd`, `/etc/group`, `/etc/hosts`, `/etc/nsswitch.conf`, `/etc/resolv.conf`, etc. are real files inside `host/wasm/rootfs.vfs`, served through the `/` mount. There is no in-kernel synthetic-file shim: any program that calls `getpwnam`, `gethostbyname`, `getservbyname`, etc. reads the same bytes a `cat /etc/passwd` would. +`/etc/passwd`, `/etc/group`, `/etc/hosts`, `/etc/nsswitch.conf`, +`/etc/resolv.conf`, and static OpenSSL policy/trust files under `/etc/ssl` are +real files inside `host/wasm/rootfs.vfs`, served through the `/` mount. Any +program that calls `getpwnam`, `gethostbyname`, `getservbyname`, or OpenSSL's +default configuration/trust lookup reads the same image bytes that `cat` would. +The kernel synthesizes `/etc/mtab` because it reports live mount state; it does +not synthesize static `/etc` policy or trust data. VFS images can also carry image-level metadata outside the guest file tree. The first declaration is `kernelAbi`, an exact `ABI_VERSION` requirement for images that carry ABI-bound Wasm programs. `MemoryFileSystem.readImageMetadata(image)` reads this declaration without materialising the filesystem, and `MemoryFileSystem.assertImageKernelAbi(image, abi)` validates it for callers that already know the running kernel ABI. Legacy/data-only images may omit the field. ### Node host -`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via `resolveForNode`, and constructs a `VirtualPlatformIO` for the kernel. Without it, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. +`NodeKernelHost` accepts `rootfsImage: "default" | ArrayBuffer | Uint8Array | undefined`. With `"default"` (the path used by the vitest suite), the worker reads `host/wasm/rootfs.vfs`, applies `DEFAULT_MOUNT_SPEC` via `resolveForNode`, and constructs a `VirtualPlatformIO` for the kernel. The image supplies both `/etc/ssl/cert.pem` and `/etc/ssl/certs/ca-certificates.crt`; Node does not silently add them to caller-supplied images. Without a rootfs image, the worker falls back to raw `NodePlatformIO` (every host path reachable) — kept for legacy callers that haven't migrated. ### Browser host -`BrowserKernel.boot({ vfsImage, ... })` is the kernel-owned VFS path. The worker restores the supplied image (per-demo `.vfs.zst`, typically built on top of the canonical rootfs as a base layer) into a `MemoryFileSystem`, applies `DEFAULT_MOUNT_SPEC` via `resolveForBrowser` (the image becomes the `/` mount; the seven scratch mounts come up empty), and layers `/dev/shm` + `/dev` on top. +`BrowserKernel.boot({ vfsImage, ... })` is the kernel-owned VFS path. The worker restores the supplied image (per-demo `.vfs.zst`, typically built on top of the canonical rootfs as a base layer) into a `MemoryFileSystem`, applies `DEFAULT_MOUNT_SPEC` via `resolveForBrowser` (the image becomes the `/` mount; the seven scratch mounts come up empty), and layers `/dev/shm` + `/dev` on top. Browser networking then replaces `/etc/ssl/certs/ca-certificates.crt` with its generated per-session MITM root; the image-owned OpenSSL configuration and compiled-in `/etc/ssl/cert.pem` trust path remain unchanged. -The legacy `kernel.spawn(programBytes, argv, { fsSab })` path is still supported for demos that own a single `MemoryFileSystem` SAB at `/` (used by `benchmark`, `erlang`, `shell`). To keep `getpwnam`/`gethostbyname` working on that path after `synthetic_file_content` was removed, the browser kernel worker overlays `/etc/*` from `rootfs.vfs` into the demo SAB at boot (`overlayEtcFromRootfs` in `host/src/browser-kernel-worker-entry.ts`), preserving any `/etc` files the demo wrote itself. This is a temporary bridge until those demos move to the `vfsImage` boot path. +The legacy `kernel.spawn(programBytes, argv, { fsSab })` path is still supported for demos that own a single `MemoryFileSystem` SAB at `/` (used by `benchmark`, `erlang`, `shell`). To keep NSS and other static system policy available on that path, the browser kernel worker recursively merges `/etc/**` from `rootfs.vfs` into the demo SAB at boot (`overlayEtcFromRootfs` in `host/src/vfs/rootfs-overlay.ts`). Existing leaf files and symlinks remain demo-owned, while existing directories are traversed so missing canonical descendants such as `/etc/ssl/openssl.cnf` are still installed. This is a temporary bridge until those demos move to the `vfsImage` boot path. ### Lazy Files @@ -421,7 +569,20 @@ const ino = mfs.registerLazyFile("/usr/bin/php", "https://cdn.example.com/php.wa await mfs.ensureMaterialized("/usr/bin/php"); ``` -Lazy file metadata (`path`, `url`, `size`, `ino`) can be transferred between instances via `exportLazyEntries()` / `importLazyEntries()` — used when forking workers that share the same SharedArrayBuffer. +Lazy file metadata (`path`, hard-link aliases, `url`, `size`, `ino`, inode +generation, and data-mutation sequence) can be transferred between instances +via `exportLazyEntries()` / `importLazyEntries()` — used when workers share the +same SharedArrayBuffer. The generation prevents an unlinked lazy inode from +transferring its URL or declared size to a later file that reuses the same +guest-visible inode number. The data sequence prevents an asynchronous fetch +from overwriting guest data written through another worker while the request +was in flight. Live cross-worker imports require both identity fields; only a +legacy image whose filesystem bytes and lazy JSON form one trusted artifact can +adopt older metadata, and only for an untouched empty stub. Filesystem rebasing +preserves hard-link identity for lazy and +concrete files rather than copying aliases into independent inodes. A rebase +walks one quiescent source snapshot, so a peer rename cannot mix lazy paths +from one namespace state with bytes from another. ### VFS Images @@ -437,6 +598,17 @@ const image: Uint8Array = await mfs.saveImage(); const fullImage: Uint8Array = await mfs.saveImage({ materializeAll: true }); ``` +Image creation is a quiescent filesystem operation. `saveImage()` rejects a +filesystem with live file or directory descriptors rather than serializing FD +tables, inode open-reference counts, or lock words as durable state. The +resulting bytes contain only filesystem state. Restore also clears those +runtime-only fields in legacy images, so a new machine never inherits handles +or locks from the image builder. Lazy-file and lazy-archive paths are collected +under the same namespace transaction as the filesystem bytes, including names +changed by another worker. `materializeAll: true` resolves both standalone and +archive-backed entries and fails instead of emitting an image that still +depends on a deferred URL. + **Restore from an image:** ```typescript @@ -454,6 +626,12 @@ const restored = MemoryFileSystem.fromImage(image, { maxByteLength: 1024 * 1024 The image must also have been built with a large enough filesystem maximum, for example `MemoryFileSystem.create(sab, 1024 * 1024 * 1024)`. `fromImage(..., { maxByteLength })` only controls the restored buffer's runtime growth ceiling; `statfs`/`df` and allocation remain capped by the image superblock maximum. +A consumer that must stage files larger than the image's recorded allocation +ceiling first calls `rebaseToNewFileSystem(requiredMaxBytes)`. Shared image +helpers never treat a partial file as complete: `writeVfsBinary` advances over +positive short writes and throws on zero/negative progress or an underlying +filesystem error, while still closing the descriptor. + Kandelo browser UI presets use this approach. Each image builder pre-populates a VFS with runtime files, directory structure, configs, and symlinks, then saves it as a `.vfs.zst` file (zstd-compressed; `saveImage()` compresses on write). At runtime, the UI fetches the file and `MemoryFileSystem.fromImage` decompresses transparently - restoring the image replaces thousands of individual file writes with a single buffer copy. The empty regions of the SharedFS allocator compress to almost nothing, so a 32 MB filesystem with a few MB of real content typically ships as a 1-3 MB download. There are two consumption patterns for VFS images, depending on whether the demo wants the kernel worker to fully own the filesystem: @@ -483,11 +661,10 @@ Build scripts are in `images/vfs/scripts/` and share common helpers (`vfs-image- **Binary format:** -The on-disk file is the raw VFS image below, wrapped in a single zstd -frame. `saveImage()` always writes the compressed form (`.vfs.zst`); -`MemoryFileSystem.fromImage()` accepts either form and auto-detects -the zstd magic (`28 B5 2F FD`) at offset 0 to decide whether to -decompress before parsing. +`MemoryFileSystem.saveImage()` returns the raw VFS image below. The image +builder helper wraps it in one zstd frame for `.vfs.zst` artifacts; +`MemoryFileSystem.fromImage()` accepts either form and auto-detects the zstd +magic (`28 B5 2F FD`) at offset 0 before parsing. Decompressed layout: @@ -495,22 +672,54 @@ Decompressed layout: Offset Size Field 0 4 Magic: 0x56465349 ("VFSI") 4 4 Version: 1 -8 4 Flags: bit 0 = lazy entries included +8 4 Flags: bit 0 = lazy files, bit 1 = lazy archives, bit 2 = metadata 12 4 SharedArrayBuffer data length (N) 16 N Raw SharedArrayBuffer bytes (block filesystem) 16+N 4 Lazy entries JSON length (M) -20+N M Lazy entries as JSON (UTF-8): [{ino, path, url, size}, ...] +20+N M Lazy-file JSON (identity, aliases, URL, declared size) +... 4+L Optional lazy-archive JSON length and bytes (when bit 1 is set) +... 4+P Optional image-metadata JSON length and bytes (when bit 2 is set) ``` ## Networking -User-visible networking is POSIX-first. Guest programs call normal AF_INET socket syscalls (`socket`, `bind`, `connect`, `listen`, `accept`, `send`, `recv`, `sendto`, `recvfrom`, `poll`, and `select`). The Rust kernel owns the socket file descriptors, UDP queues, TCP listener state, loopback routing, and errno behavior. Host transports plug in below that layer through `NetworkIO`; they are backends, not the userspace-visible abstraction. - -Loopback addresses are per Kandelo machine. Routed virtual addresses are explicit backend addresses. For example, the browser network lab attaches separate machines to addresses such as `10.88.0.2`, `10.88.0.3`, and `10.88.0.4`; traffic to `127.0.0.1` stays inside one machine, while traffic to those virtual addresses can cross machines through the backend. +User-visible networking is POSIX-first. Guest programs call normal AF_UNIX, AF_INET, and partial AF_INET6 socket syscalls (`socket`, `bind`, `connect`, `listen`, `accept`, `send`, `recv`, `sendto`, `recvfrom`, `poll`, and `select`). The Rust kernel owns the socket file descriptors, datagram queues, stream listener state, loopback routing, and errno behavior. Host transports plug in below that layer through `NetworkIO`; they are backends, not the userspace-visible abstraction. + +AF_INET and AF_INET6 receive queues are currently bounded at 128 datagrams per +socket. Once that fixed internal queue is full, a newly arriving UDP datagram +is dropped and the already-queued datagrams retain their order. `SO_RCVBUF` +requests are stored but do not size this queue; `getsockopt` continues to report +the fixed default capacity. AF_UNIX datagrams use the same bounded storage but +are reliable: a full receive queue makes the send enter the host's blocking +retry path, or returns `EAGAIN` immediately for an `O_NONBLOCK` or +`MSG_DONTWAIT` send, without discarding queued messages. Queue-capacity, +association, shutdown, close, and pathname changes wake blocked writers and +writable readiness waiters so they can observe either capacity or the new +immediate error. + +IPv4 limited broadcast (`255.255.255.255`) is permission-gated: a datagram +send without `SO_BROADCAST` fails with `EACCES`. Kandelo does not provide raw +broadcast delivery, so enabling the option only passes that gate; the send then +reaches the active routing/backend boundary. Directed broadcast addresses are +not modeled. + +For AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` +extension reports the original datagram length while copying no more than the +supplied buffer. Without `MSG_PEEK`, the datagram is consumed and any uncopied +suffix is discarded; with `MSG_PEEK`, it remains queued. This receive-side +truncation does not weaken AF_UNIX send reliability or its full-queue +backpressure contract above. The current `recvmsg()` wrapper does not populate +output `msg_flags`, so it cannot report output `MSG_TRUNC` there. + +Loopback addresses are scoped to one Kandelo machine, but not every socket path is machine-wide yet. IPv4 and IPv6 loopback TCP and AF_UNIX streams have explicit cross-process paths. Current in-kernel IPv4/IPv6 loopback datagrams, AF_UNIX datagrams, and IPv4 multicast delivery are confined to the sending process. Forked sockets retain their kernel-local bind reservations and local lookup targets, but host-backed UDP endpoint registrations are not yet shared or transferred between processes. AF_INET6 represents `sockaddr_in6`, supports `::`/`::1`, and models dual-stack wildcard stream-port reservation, but it has no external or virtual-network IPv6 transport and no IPv6 multicast delivery. AF_INET6 datagrams therefore report `IPV6_V6ONLY=1`; disabling it fails until dual-stack datagram routing exists. + +Routed virtual IPv4 addresses are explicit backend addresses. For example, the browser network lab attaches separate machines to addresses such as `10.88.0.2`, `10.88.0.3`, and `10.88.0.4`; traffic to `127.0.0.1` stays inside one machine, while traffic to those virtual addresses can cross machines through the backend. ### Local Virtual Network -`LocalVirtualNetwork` (`host/src/networking/virtual-network.ts`) is an in-memory `NetworkIO` backend for multiple Kandelo machines in the same JS session. Each machine receives a `VirtualNetworkBackend` with a stable virtual IPv4 address and optional hostnames. The backend delivers UDP datagrams as bounded message queues and creates paired TCP streams for accepted connections. When a machine detaches, its listeners and endpoints are removed and connected TCP peers observe normal close/reset style readiness through the socket layer. +`LocalVirtualNetwork` (`host/src/networking/virtual-network.ts`) is an in-memory `NetworkIO` backend for multiple Kandelo machines in the same JS session. Each machine receives a `VirtualNetworkBackend` with a stable virtual IPv4 address and optional hostnames. The backend delivers UDP datagrams as bounded message queues and creates paired TCP streams for accepted connections. When a machine detaches, its listeners and endpoints are removed. Direct virtual endpoints observe an explicit connection reset; an accepted pipe-bridged endpoint currently maps that reset to EOF/EPIPE because the pipe ABI has no pending-socket-error channel. + +Normal TCP close is distinct from that abort path. Bytes queued by the closing endpoint drain before its FIN, and the peer drains those bytes before `recv` reports EOF. The in-kernel loopback and local virtual transports retain an orphaned receive sink that discards later peer sends until the peer closes its own write half; they do not invent a fixed number of successful writes after FIN. The Node backend uses `net.Socket` half-open state and `destroySoon()` so the operating system determines later reset timing after queued bytes and FIN. Explicit receive shutdown remains a refusal path. Enabled `SO_LINGER` is rejected until reset and timed-close modes can be carried coherently through every transport. This backend is used by `apps/browser-demos/pages/network/`, which boots multiple local machines and verifies UDP datagram delivery with `nc -u`, TCP stream delivery with `nc`, and HTTP over virtual TCP with `curl`. @@ -526,6 +735,8 @@ Browsers cannot create external raw TCP or UDP sockets. Local loopback and `Loca 2. **Service Worker HTTP Bridge**: For server demos (nginx, WordPress), a service worker intercepts browser `fetch()` requests to a configurable URL prefix (e.g., `/app/`) and forwards them to the kernel via a MessagePort connection pump. The kernel injects the request as a TCP connection to nginx's listening socket, and nginx's response flows back through the pipe to the service worker. +`TcpNetworkBackend`, `FetchNetworkBackend`, `TlsNetworkBackend`, and `LocalVirtualNetwork` share one numeric-address and hostname validator. It accepts decimal one-, two-, three-, and four-component IPv4 forms within their component widths, rejects malformed or overflowing numeric forms, enforces ASCII host-label syntax and DNS length limits, and preserves one trailing root dot. The Node TCP backend resolves validated names through the host resolver. The browser HTTP fetch/TLS bridges synthesize IPv4 mappings for syntactically acceptable DNS names; `LocalVirtualNetwork` resolves only aliases registered by attached machines. None of the browser paths adds browser DNS resolution or AF_INET6 transport. + WebRTC or proxy-based external transports should attach as additional `NetworkIO` backends behind the same POSIX socket layer rather than adding host-specific socket APIs visible to guest programs. ## Framebuffer (`/dev/fb0`) @@ -600,7 +811,7 @@ The kernel exposes an OSS-style `/dev/dsp` character device so unmodified Linux The kernel does **not** mix or synthesize audio. The user program (DOOM's mixer in `i_kernel_sound.c` plus the OPL2 software synth in `i_oplmusic.c` + `opl/opl3.c` for music) does that work and writes interleaved S16_LE frames; the kernel ring is just transport. fbDOOM's mixer produces 1280 stereo frames per ~28 ms game tic — slightly more than the 1260 frames the AudioContext consumes per tic — so the ring stays full enough to hide drain jitter, and the drop-oldest-on-overflow policy keeps memory bounded. -Single-open semantics match the typical OSS exclusive-grab model. Owner ownership is released on `close` of the last `/dev/dsp` fd, on `execve`, and on process exit; the ring is flushed at the same time so a successor open hears silence rather than the tail of the previous program. ABI version bumped 7 → 8 to register the new `kernel_drain_audio(i64, i32) -> i32` export plus the three readouts `kernel_audio_sample_rate / channels / pending`. The OSS ioctl encodings live in `crates/shared/src/lib.rs::oss`. +Single-open semantics match the typical OSS exclusive-grab model. Ownership is released on `close` of the last `/dev/dsp` fd or on process exit; a surviving non-CLOEXEC fd retains both ownership and queued samples across exec. The ring is flushed when ownership is released so a successor open hears silence rather than the previous owner's tail. ABI version bumped 7 → 8 to register the new `kernel_drain_audio(i64, i32) -> i32` export plus the three readouts `kernel_audio_sample_rate / channels / pending`. The OSS ioctl encodings live in `crates/shared/src/lib.rs::oss`. ## Signal Subsystem @@ -614,6 +825,45 @@ Signals are delivered at syscall boundaries. When a process has a pending signal Features: RT signal queuing with `si_value`, cross-process `kill`/`killpg`, `sigaltstack` with shadow stack swap, `sigsuspend`, `sigtimedwait`, `setitimer`/`alarm` via host timers. +POSIX timer scheduling is split at an explicit ownership boundary. The shared +Node/browser host owns wall-clock `setTimeout`/`setInterval` scheduling, while +the kernel owns the timer object, notification-pending state, exact +`SIGEV_THREAD_ID` target, `SI_TIMER` metadata, overrun accounting, and queued +signal lifetime. At each expiration the host calls the ABI-required +`kernel_posix_timer_fire` export and wakes only the thread selected by the +kernel (or the eligible process-wide waiters for `SIGEV_SIGNAL`). The host does +not synthesize timer signals or fall back to a process-wide notification. + +Musl implements POSIX `SIGEV_THREAD` with a detached helper pthread and an +exact-thread kernel notification. The helper retains the callback and native +`union sigval` locally—including a full-width wasm64 pointer—while the +kernel-facing `sigevent` remains a fixed four-i32 wire. Direct wasm64 signal +notifications therefore remain limited to `sival_int` until that wire is +extended in a later ABI. + +Normal exit status and signal termination are stored separately. `_exit()` and +`exit_group()` retain the low eight status bits, including values 128 through +255; a default terminating signal records its signal number independently. +`waitpid()` therefore emits the POSIX wait encoding without guessing that a +high normal exit code was a signal, while host lifecycle callbacks may still +present the conventional shell-style `128 + signal` value. + +Child status is a Rust-owned, per-process record covering stop, continue, and +termination. POSIX replacement semantics apply: a new transition replaces +older unconsumed status, ordinary waits consume it, and `waitid(WNOWAIT)` only +peeks. Stopped and continued reports never reap; consuming termination status +does. The host validates every guest output range before asking the kernel to +consume a record, so an `EFAULT` cannot lose child status or reap a zombie. + +Default stop actions park execution cooperatively at the syscall boundary. The +kernel emits a process-transition event, and the shared Node/browser host holds +prepared completions by exact channel identity, including every pthread +channel, until SIGCONT. SIGCONT changes state immediately even when blocked, +ignored, or caught; SIGKILL can terminate a stopped process. Current Wasm +workers do not provide arbitrary-instruction preemption, so CPU-bound code that +never reaches a syscall cannot be suspended immediately; that boundary is +documented in `posix-status.md` rather than hidden behind host-specific behavior. + ## Browser-Specific Architecture In the browser, an additional layer wraps the kernel: @@ -635,7 +885,7 @@ Main Thread Kernel Worker The remaining methods (`pipeRead`/`pipeWrite`, `injectConnection`, stdin/PTY routing, framebuffer registry mirroring, HTTP bridge handoff) are pid-addressed and work the same in both boot paths. -**Browser kernel worker** (`host/src/browser-kernel-worker-entry.ts`): Dedicated web worker that hosts `CentralizedKernelWorker`, following the standard architecture requirement. Process workers are sub-workers created by the kernel worker. The dedicated worker provides a clean event loop for fast `Atomics.waitAsync` notification delivery and avoids V8's microtask freeze bug that occurs on the main thread. +**Browser kernel worker** (`host/src/browser-kernel-worker-entry.ts`): Dedicated web worker that hosts `CentralizedKernelWorker`, following the standard architecture requirement. Process workers are sub-workers created by the kernel worker. Syscall notification remains event-driven through `Atomics.waitAsync`, not channel polling. The browser config uses batch size 1 so every relisten and already-`PENDING` dispatch is deferred through the MessageChannel-backed `setImmediate` queue; this keeps syscall handling and worker messages progressing together under multi-process bridge load. Node.js retains its native/default batching unchanged. **dinit (PID 1)** (`packages/registry/dinit/`): Service-supervised demos boot dinit v0.19.4 (cross-compiled to wasm32) as the first process via `kernel.boot({ argv: ["/sbin/dinit", "--container", ...] })`. The service tree is baked into `/etc/dinit.d/*` at image-build time via `addDinitInit()` in `dinit-image-helpers.ts`. Service types in use: `process` (long-running daemons), `scripted` (one-shot bootstraps that exit cleanly), and `internal` (dependency-only nodes used to express "boot the whole tree" or "pick this engine"). dinit handles SIGCHLD reaping, restarts disabled by default, and inter-service `depends-on` ordering. diff --git a/docs/binary-releases.md b/docs/binary-releases.md index aa6f26c134..e5b47b0103 100644 --- a/docs/binary-releases.md +++ b/docs/binary-releases.md @@ -337,9 +337,11 @@ For each declared arch in the package's `arches = [...]` (default - `abi_versions` must contain the in-tree `ABI_VERSION`. - `cache_key_sha` must match the resolver's locally-computed cache-key sha (catches recipe drift). -6. Places `binaries/programs//.wasm` symlinks pointing - into the cache, so browser/Node demos can load by relative path - without re-fetching. +6. Places each program output under `binaries/programs//` using the + manifest's output layout, and places declared non-Wasm runtime files under + `binaries/programs///`. Both are symlinks into the + validated cache, so browser/Node image builders load the same bytes without + re-fetching. Local builds use the identical layout under `local-binaries/`. On any verification failure, the resolver logs a warning and falls through to a source build (the package's build script). This diff --git a/docs/browser-support.md b/docs/browser-support.md index 314b673586..057f5c4d68 100644 --- a/docs/browser-support.md +++ b/docs/browser-support.md @@ -50,8 +50,13 @@ Service Worker ──MessagePort──> Kernel Worker │ ### Key Design Decisions -- **Kernel in dedicated worker**: Enables `Atomics.waitAsync` without V8 microtask chain freeze bug (main-thread-only). No need for MessageChannel-based polling. Zero UI jank regardless of syscall load. +- **Kernel in dedicated worker**: Browser syscall notification remains event-driven through `Atomics.waitAsync`; it does not poll channels. The browser config uses batch size 1 so every relisten and already-`PENDING` dispatch is deferred through the MessageChannel-backed `setImmediate` queue, allowing syscall handling and worker messages to keep progressing together under multi-process bridge load. Node.js keeps its native/default batching unchanged. - **Kernel-owned VFS** (preferred path, `kernelOwnedFs: true` + `kernel.boot()`): the kernel worker restores a pre-built VFS image and exec()s `argv[0]` as the first process. The main thread never instantiates a `MemoryFileSystem` and is not in the FS hot path. Service-supervised demos run dinit (PID 1) inside this image; single-program demos exec the language interpreter directly. + Browser harnesses that must stage a transient file between process spawns use + `BrowserKernel`'s worker RPC methods (`readFileSnapshotFromVfs`, + `writeFileToVfs`, and `unlinkFileFromVfs`). The owning worker performs those + mutations through the mounted VFS; the main thread never receives the live + VFS `SharedArrayBuffer`. - **Legacy shared VFS** (`memfs:` constructor option + `kernel.spawn()`): main thread holds a `MemoryFileSystem` and shares the SAB with the kernel worker. Used by demos that fetch transient binaries at runtime (test runners, REPLs that load arbitrary user code, benchmark suites). Kept in place until the kernel grows a "spawn-into-running-kernel" path that doesn't need a main-thread pid. - **Exec reads from filesystem**: Like a real OS, `exec()` reads binaries from the kernel-side `MemoryFileSystem`. Programs are baked into the VFS image at build time (or written by the page in the legacy path before spawning). Symlinks are used for multicall binaries (e.g., coreutils). - **dinit (PID 1) for service supervision**: Multi-process demos (nginx, redis, mariadb, nginx-php, wordpress, lamp, mariadb-test) bake `/sbin/dinit` and per-service files under `/etc/dinit.d/` into the VFS image via `addDinitInit()` (`images/vfs/scripts/dinit-image-helpers.ts`). dinit handles SIGCHLD reaping, `depends-on` ordering, and bootstrap-then-daemon chains. Page code waits for service-ready via `onListenTcp` (port-bind) callbacks, then starts driving the demo over kernel-loopback TCP or the HTTP bridge. @@ -81,12 +86,17 @@ Injected TCP pipes live in the kernel's global pipe table (`pid == 0` for connection in any nginx worker. The standalone nginx image runs with `master_process on` and `worker_processes 2`. +AF_UNIX stream listeners use the same shared-queue ownership model. This is the +path used by pre-fork PHP-FPM workers: a connection is queued once and whichever +worker wins `accept()` materializes its own connected socket around the global +pipe pair. + ## Capabilities ### Multi-Process - `fork()` via `wasm-fork-instrument` snapshot/restore — child runs in new sub-worker with copied memory - `exec()` reads program binary from the shared filesystem, replaces process -- `posix_spawn()` — fork+exec with file actions (addchdir, addfchdir, addclose, adddup2) +- `posix_spawn()` — non-forking child creation with file actions (addchdir, addfchdir, addclose, adddup2) - Process groups, wait/waitpid, cross-process signals, pipes ### Threads @@ -94,16 +104,20 @@ connection in any nginx worker. The standalone nginx image runs with - Used by MariaDB (5 threads), Redis (3 background threads) ### Networking -- POSIX AF_INET TCP and UDP inside the kernel, including local loopback and virtual machine-to-machine networking +- POSIX AF_INET TCP and UDP inside the kernel, including local loopback and virtual IPv4 machine-to-machine networking +- Partial AF_INET6 streams and datagrams for `::`/`::1`; loopback streams have a cross-process path, while datagrams remain process-local, and neither provides external or virtual-network IPv6 +- In-kernel IPv4/IPv6 loopback datagrams, AF_UNIX datagrams, and IPv4 multicast are process-local; machine-wide datagram routing is still pending - `LocalVirtualNetwork` attaches multiple browser Kandelo machines to virtual IPv4 addresses in one browser session +- Browser networking backends preserve valid decimal one-, two-, three-, and four-component IPv4 forms, reject malformed/overflowing numeric forms, enforce ASCII host-label syntax and DNS length limits, and synthesize IPv4 addresses only for acceptable hostnames; they do not provide AF_INET6 DNS/transport - GNU Netcat (`nc`) and `curl` run against those virtual sockets in the network lab at `/pages/network/` - Service worker cookie jar for session persistence (WordPress) - nginx serves static files and proxies to PHP-FPM via loopback TCP ### Filesystem - `MemoryFileSystem` — SharedArrayBuffer-based VFS shared between main thread and kernel worker -- `OpfsFileSystem` — Origin Private File System for browser persistence +- `OpfsFileSystem` — Origin Private File System for browser persistence. Its current stat metadata has no stable inode identity, so regular-file `MAP_SHARED` returns `ENOTSUP` instead of using unsafe pathname identity; `MAP_PRIVATE` is unaffected. - `DeviceFileSystem` — `/dev/null`, `/dev/zero`, `/dev/urandom`, `/dev/ptmx` +- Stable-identity regular files can be shared across process memories through the host mapping cache, but updates become visible at syscall boundaries rather than immediately on direct loads/stores. Cross-process futex waits/wakes remain unsupported; see [architecture.md](architecture.md#shared-mapping-coherence). ### Terminal - PTY support with full line discipline @@ -127,7 +141,7 @@ connection in any nginx worker. The standalone nginx image runs with ### Audio output (`/dev/dsp`) - The kernel exposes an OSS-style `/dev/dsp` character device. User programs `open(O_WRONLY)`, configure rate / channels / format via `SNDCTL_DSP_*` ioctls, and `write()` interleaved 16-bit-LE PCM. The kernel buffers samples in a 256 KiB ring (~1.5 s of stereo S16 @ 44.1 kHz). On overflow the *oldest* whole frame drops — same trade-off real OSS hardware makes under hardware overrun. - Demo pages drive a `setInterval` loop (~50 ms cadence) that calls `BrowserKernel.drainAudio(maxBytes)`. The kernel-worker drains the ring via the `kernel_drain_audio` wasm export (which respects whole-frame boundaries so stereo L/R never tear) and posts the bytes back. Main thread converts S16 → Float32, builds an `AudioBuffer`, and schedules an `AudioBufferSourceNode` on the `AudioContext` clock with a small lookahead so brief drain hiccups don't underrun. -- Single-owner device. Owner is released on close-of-last-fd / `execve` / `exit`; the ring is flushed at the same time so a successor open starts from silence. Format must be `AFMT_S16_LE`; other formats are `EINVAL`. +- Single-owner device. A non-CLOEXEC fd retains ownership and queued samples across `execve`; last close or process exit releases the owner and flushes the ring so a successor starts from silence. Format must be `AFMT_S16_LE`; other formats are `EINVAL`. - **AudioContext gesture requirement.** `new AudioContext()` starts suspended in modern browsers and only resumes after a user gesture. The DOOM demo creates the context immediately after the user's "Start" click (which is itself a gesture), so `audioCtx.resume()` succeeds without a separate prompt. ## Browser Demos @@ -336,6 +350,11 @@ For local browser artifacts, force a rebuild with `./run.sh rebuild `. | LAMP | `lamp.vfs.zst` | `bash images/vfs/scripts/build-lamp-vfs-image.sh` | MariaDB + WP + configs | | MariaDB test | `mariadb-test.vfs.zst` | `bash images/vfs/scripts/build-mariadb-test-vfs-image.sh` | MariaDB + test suite | +The standalone MariaDB demo and MariaDB test images run `mariadbd` as the +`mysql` account (uid/gid 101). Their writable `/data` directories are +therefore serialized as `101:101` with mode `0775`; `/tmp` remains a +root-owned `01777` sticky directory. + VFS images are `.gitignore`d and must be built locally. The `run.sh` script handles this automatically (e.g., `./run.sh browser` builds any missing VFS images before starting the dev server). Homebrew-derived browser images are published through the package-source 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/fork-instrumentation.md b/docs/fork-instrumentation.md index 8b68e380ef..f93ae0cbfa 100644 --- a/docs/fork-instrumentation.md +++ b/docs/fork-instrumentation.md @@ -38,6 +38,10 @@ ABI version: `12` (see - Do not keep compiler/linker flags solely for the retired legacy path. The fork instrumenter does not require preserved function names or onlylists; if a build keeps debug-info flags, it should be for a current diagnostic reason. +- On Unix hosts, the CLI preserves the input Wasm file's permission mode on + its output, including when `--output` names the input file. Package build + scripts can therefore instrument installed executables in place without + making them non-executable. ## State machine @@ -106,6 +110,37 @@ wpk_fork_state() -> i32 Returns current state. Exported for host-side assertions. ``` +The five exports identify the state-machine ABI, but they do not prove which +import seeded call-graph discovery. The tool therefore also emits the custom +section `kandelo.wpk_fork.capabilities`. Its two-byte payload is +`[version, flags]`; version 1 defines: + +- bit 0 (`0x01`): the module was instrumented with `--entry env.fork`, so an + `env.fork`-importing side module has complete side-entry coverage; +- bit 1 (`0x02`): a default-entry main module imported Kandelo's dynamic-linker + functions and conservatively instrumented every `call_indirect` boundary + plus its direct callers. + +Capability enforcement follows the compiled kernel ABI. ABI 16 predates this +section, so an artifact with no section retains the legacy five-export fallback +and can still coordinate a main/side-module fork. If an ABI-16 artifact does +carry the section, its marker is authoritative and malformed, unknown, or +role-inconsistent claims fail loudly. Starting with ABI 18, the +role-appropriate bit is mandatory: generic five-export artifacts and binaries +produced by the older call-graph pass fail with a rebuild diagnostic. This +threshold ensures that mandatory enforcement and the incompatible artifact +contract activate in the same ABI-bump commit. Changing the meaning or encoding +of these capability claims changes fork replay assumptions and must follow the +ABI-versioning policy. + +ABI 17 was intentionally skipped. ABI 18 activated the mandatory role marker. +ABI 16 remains only a historical compatibility boundary. The reconstructed +line enforces role claims and this ABI 36 epoch adds side-module replay state +and pthread-fork arbitration. ABI-16 artifacts without a marker remain +historical inputs for the parser's explicit compatibility tests; they do not +satisfy an ABI-36 launch. Any future capability-contract change must still +advance `ABI_VERSION` and regenerate `abi/snapshot.json` atomically. + `ptr` is `i32` on wasm32 user programs and `i64` on wasm64 user programs. The tool picks the pointer width from the module's primary memory — a memory64 memory yields `i64`, anything else yields `i32`. @@ -168,6 +203,59 @@ fork in the child before exec), `host/test/fork-instrument-coverage.test.ts` P-06 (`pthread_create` worker calls `fork`), and K-03 (`pthread_cleanup_push` handler calls `fork`). +## Fork from a dlopened side module + +The supported dynamic-linking shape is a direct main-module `call_indirect` +into one side-module instance whose call stack reaches `env.fork`: + +1. Instrument the main program normally. If it imports Kandelo's dlopen host + functions, the tool marks and preserves all possible dynamic indirect-call + boundaries. +2. Instrument the fork-capable side module with `--entry env.fork`. It receives + its own fork save buffer and versioned side-entry capability. +3. The process worker unwinds the side module, then the main module. Fork replay + restores dlopen instances at their exact memory and table bases, rewinds the + main module, then rewinds the active side module. + +The main fork trampoline is captured before side exports enter the symbol +table, so a later extension cannot interpose the coordinator's `fork` target. +Failed dlopen attempts may leave non-shrinkable null table gaps; each successful +archive entry records its exact parent table base, and child replay pads to and +validates that base. + +The loader preserves ordinary independent multi-extension loading. When a +fork-capable extension participates, it rejects statically visible +side-to-side function/GOT linkage and side-originated `dlopen`/`dlsym`, because +an intervening side-module frame would need a third ordered unwind. Opaque +function pointers passed through main-module memory or the shared table cannot +currently be attributed to their originating module; using such a pointer to +create a side A -> side B -> fork path is unsupported and is not yet guaranteed +to fail before control-flow corruption. A future module-activation protocol is +required to close that residual. + +Pthread workers do not own the process worker's side-module instances, table, +or exception-tag identities. `dlopen()` from a pthread consequently returns +NULL with a precise `dlerror()`. Once the process main worker has published a +dlopen archive entry, `fork()` from a pthread returns `ENOTSUP` without +creating a child. A host-private atomic lock prevents main-worker dlopen from +racing the pthread's archive check and is held through unwind, SYS_FORK/memory +copy, and parent rewind; the child clears its copied lock before replay. Fork +from a pthread remains supported while that process-wide archive is empty. + +For TLS-bearing side modules, each archive entry also preserves the live +positive `__tls_base`. Replay restores only that mutable global using the +process pointer type. It does not call `__wasm_init_tls`: the child memory copy +already contains live TLS, and reinitialization would overwrite C++ landing-pad +and application `thread_local` state. TLS-relative exports relocate from that +base, while `__tls_size` and `__tls_align` remain scalar constants. + +Every participating module still uses the fixed 16 KiB save-buffer limit +described below. A dynamically allocated side buffer avoids overlap with the +main control slab but does not make deep/unbounded frame use safe. Before the +worker sends `SYS_FORK`, it checks both the main module's buffer and the active +side module's independent buffer; either overrun terminates the process instead +of creating a child from corrupted continuation state. + ## Save buffer format All offsets are byte-exact, all values little-endian. `P` is pointer width @@ -188,6 +276,17 @@ time and 0 in modules that do not contain plain-catch capture sites. internal `Runtime` struct, and `wpk_fork_unwind_begin` writes `buf + frames_start_offset` into `*(buf + 0)` on every invocation. +After an unwind, the host compares this absolute `current_pos` with the explicit +`buf + FORK_SAVE_BUFFER_SIZE` bound before sending `SYS_FORK`. Main-process and +pthread buffers sit next to their syscall channels; a fork-capable side module +has a separately allocated buffer recorded in its active-fork state. A cursor +beyond either end means frame writes crossed that module's reserved boundary. +The process fails with the required and reserved byte counts instead of +creating a child from corrupted continuation state. This is detection, not +prevention: the instrumented unwind has already written past the fixed reserve, +so the host discards the process and its memory. Increasing or making the +reserve elastic is a separate ABI layout change. + For wasm32 (`P = 4`) with a module that declares three additional scalar mutable globals totaling 16 bytes (e.g. `__stack_pointer`, `__tls_base`, one user i64) and one fork-path function with a single `(catch $tag (param i32))` diff --git a/docs/future-improvements.md b/docs/future-improvements.md index 2587342f27..fdba1bd81f 100644 --- a/docs/future-improvements.md +++ b/docs/future-improvements.md @@ -1,15 +1,29 @@ # Future Improvements -Technical debt and improvement opportunities. None are bugs — all are deferred enhancements. +Technical debt, deferred enhancements, and explicitly documented conformance +gaps. Listing an item here does not imply that the current behavior is fully +supported. ## Kernel -### Per-process OFD storage breaks POSIX fork OFD-sharing -Open File Descriptions live inside `Process` (`crates/kernel/src/ofd.rs`'s `OfdTable`), not in a kernel-global table. POSIX.1-2017 §2.4.1 requires that "each of the child's file descriptors shall refer to the same open file description as the corresponding file descriptor of the parent" — meaning the seek pointer, status flags, and pending I/O state are SHARED across fork siblings. Our model deep-clones the parent's `OfdTable` into the child, so each process has independent copies — independent seek pointers, independent status flags after fork. - -A program that does `fork()` then both processes append to the same fd expecting interleaved output (a common idiom for cooperative log writers, parallel `make` job-server pipes, or any pattern that relies on shared-position semantics) will silently produce garbled output instead. No regression test exercises this today; it's structurally there. - -The cleanest redesign: move OFDs to a kernel-global `OfdTable` and have `Process` hold `FdTable` where `OfdRef` is a stable index. Fork's "fd inheritance" becomes the trivial pointer/refcount operation it should be (no deep clone, no per-resource cross-process refcount machinery). The non-forking `posix_spawn` work added a lot of refcount bookkeeping (host file handles, global pipes, PTYs, listener backlogs, host_net_handle) to compensate for the per-process model — that machinery would mostly disappear with kernel-global OFDs. +### Per-process ordinary OFD metadata still breaks POSIX fork sharing +Open File Descriptions live inside `Process` (`crates/kernel/src/ofd.rs`'s +`OfdTable`), not in a kernel-global table. POSIX requires a child descriptor to +refer to the same open file description as its parent counterpart. Kandelo now +retains exact refcounted backings for stateful objects that cannot be safely +reconstructed: pipes, sockets, PTYs, eventfd, timerfd, signalfd, memfd, and +procfs snapshots/cursors. Those fixes preserve the underlying object state but +do not make the ordinary OFD record itself global. + +Regular-file seek positions, status flags, and owners are therefore still +deep-copied at fork/spawn. A program that forks and coordinates writes through +one inherited regular fd can observe divergent positions or flag changes. + +The cleanest redesign is still to move OFDs to a kernel-global `OfdTable` and +have `Process` hold `FdTable`, where `OfdRef` is a stable index. Fork's +fd inheritance then becomes the pointer/refcount operation POSIX describes, +and much of the per-resource inheritance bookkeeping can collapse into the +global OFD lifetime. Cost of the redesign: locking / borrow-checker complexity around the global table, plus a careful migration that doesn't regress the syscall hot path. Worth scheduling on the next big initiative — the savings compound across fork, spawn, exec, and dup. @@ -30,6 +44,50 @@ When `host_call_signal_handler` fails (invalid function table index, handler thr **Files:** `crates/kernel/src/wasm_api.rs` — `deliver_pending_signals` +### Make cross-process shared memory immediate and futex-addressable + +Anonymous `MAP_SHARED` inherits one host-owned backing across fork; SysV SHM +and stable-identity regular-file mappings share backings across separately +attached or mapped processes. Because each PID still owns a different +WebAssembly `Memory`, coherence happens only when a process crosses a syscall +boundary: the host merges bytes changed relative to that process's snapshot and +then imports peer changes. A direct store does not immediately change another +PID's memory, and futex WAIT/WAKE cannot target the peer's separate +`SharedArrayBuffer`. + +Closing this gap requires a memory architecture or host protocol that supports +both immediate observation and wakeups, not just periodic byte merging. Any +design must preserve independent process address spaces, fork continuation, +Node/browser parity, and signal/cancellation behavior. It also needs explicit +performance evidence: the current boundary coordinator runs in the syscall hot +path, and its cost has not yet been established by before/after micro and full +application benchmarks on both hosts. + +**Files:** `host/src/kernel-worker.ts`, `host/src/worker-main.ts`, +`host/src/browser-kernel-worker-entry.ts`, +`host/src/node-kernel-worker-entry.ts` + +### Close the remaining regular-file `MAP_SHARED` gaps + +The mapping cache deliberately rejects objects it cannot identify or keep +alive safely. Current OPFS stats use inode zero, so OPFS `MAP_SHARED` returns +`ENOTSUP`; in-kernel memfds also return `ENOTSUP` because they do not expose the +host handle used by the file page cache. An initial mapping also needs to reopen +the descriptor's current pathname, so mapping an already renamed/unlinked fd +can fail even though an established mapping survives later close, rename, or +unlink through its stable handle. + +Further gaps are observable VM semantics rather than cache bookkeeping. Stores +beyond the current file size are zero-filled or discarded on refresh/writeback +instead of raising Linux `SIGBUS`, and writers outside Kandelo's direct file +syscall paths do not invalidate cached pages. Complete support needs stable +OPFS identity, a kernel-owned memfd mapping bridge, external invalidation (or a +documented ownership boundary), and a Wasm mechanism or instrumentation for +faulting beyond EOF. + +**Files:** `host/src/kernel-worker.ts`, `host/src/vfs/opfs.ts`, +`host/src/vfs/vfs.ts`, `crates/kernel/src/descriptor_backing.rs` + ## Browser ### PTY terminal integration with xterm.js diff --git a/docs/package-management.md b/docs/package-management.md index b2103e0cf7..6a6e8a145d 100644 --- a/docs/package-management.md +++ b/docs/package-management.md @@ -150,8 +150,8 @@ The split is load-bearing post the Required fields: ```toml -name = "zlib" # logical library name -version = "1.3.1" # upstream version +name = "zlib" # logical library name; one safe path component +version = "1.3.1" # upstream version; one safe path component depends_on = [] # ["zlib@1.3.1", ...] — exact versions, no ranges [source] @@ -176,8 +176,51 @@ script_path = "packages/registry/zlib/build-zlib.sh" libs = ["lib/libz.a"] # must exist post-build headers = ["include/zlib.h", "include/zconf.h"] pkgconfig = ["lib/pkgconfig/zlib.pc"] +files = ["share/runtime-data.bin"] # other runtime data ``` +Program packages use `[[outputs]]` for executable/side-module artifacts. A +non-Wasm file required at runtime is declared separately so it remains part of +the same reproducible archive and cache key: + +```toml +[[outputs]] +name = "php" +wasm = "php.wasm" + +[[runtime_files]] +artifact = "icu.dat" # relative to the package cache/archive +guest_path = "/usr/lib/php/icu.dat" # installation path in a VFS image +mode = 420 # optional decimal TOML; default 0644 +``` + +`[[runtime_files]]` is program-only. Artifact paths are normalized portable +relative paths; guest paths are normalized absolute POSIX paths; files, +ancestor paths, and resolver-mirror destinations may not collide. The resolver +requires regular non-symlink runtime files after fresh builds, cache hits, and +remote fetches. It mirrors them at +`{local-,}binaries/programs///` independently of the +number of `[[outputs]]` entries. Missing fetched files make the archive stale +and trigger source fallback (or a hard failure in fetch-only mode). + +Repo-side VFS/test builders query the authoritative path and mode with +`xtask build-deps runtime-file-metadata `; they must not +scan library caches or invent environment-only guest paths. Published VFS +images contain the installed bytes already, so this query is a build-tool +contract rather than a runtime host API. The structured metadata also lists +the package's complete mirror closure (every `[[outputs]]` artifact plus every +`[[runtime_files]]` file). Repo-side consumers resolve that set from one +complete provenance root: a partial local override may fall back wholesale to +a complete fetched package, but local, fetched, and installed-package tiers +are never combined. If artifacts exist but no tier has the complete accepted +closure, resolution fails loudly. + +Top-level keys are closed-schema: misspellings such as `[[runtime_file]]` +(singular) are rejected instead of silently dropping a runtime dependency. +Package names, versions, dependency names, and exact dependency-version tokens +must each be safe single filesystem components; `/`, `\`, NUL, `.` and `..` +spellings are rejected before cache, archive, or registry path construction. + `package.toml` **must NOT** carry `revision`, `[binary]`, `[build].repo_url`, or `[build].commit`. Those moved to `build.toml` during the binary-resolution-via-index-ledger migration; @@ -248,9 +291,11 @@ declared arch). Defaults to `["wasm32"]` when omitted. The default reflects the project's wasm64 build policy: the kernel is wasm64, but most ported user-space programs (dash, vim, perl, etc.) ship wasm32 only. The packages that currently opt into -wasm64 are MariaDB, MariaDB-VFS, PHP, and the libraries PHP -depends on transitively (zlib, openssl, sqlite, libxml2). Adding -a manifest to the wasm64 set is one line: +wasm64 are MariaDB, MariaDB-VFS, libcxx, zlib, OpenSSL, and SQLite. +PHP, libiconv, and libxml2 remain wasm32-only because their current recipes +invoke the wasm32 SDK explicitly. Adding a manifest to the wasm64 set is one +line, but the corresponding recipe and runtime/link validation must support +that architecture truthfully: ```toml arches = ["wasm32", "wasm64"] @@ -351,7 +396,7 @@ that doesn't respect them cannot be cached safely. | Variable | Meaning | |---|---| -| `WASM_POSIX_DEP_OUT_DIR` | Temp dir the script must install into. Layout matches `outputs.libs` / `outputs.headers` / `outputs.pkgconfig` relative paths. | +| `WASM_POSIX_DEP_OUT_DIR` | Temp dir the script must install into. Layout matches `outputs.libs` / `outputs.headers` / `outputs.pkgconfig` / `outputs.files` relative paths. | | `WASM_POSIX_DEP_NAME` | `name` from package.toml. | | `WASM_POSIX_DEP_VERSION` | `version` from package.toml. | | `WASM_POSIX_DEP_REVISION` | Effective package revision after `build.toml` is overlaid. | @@ -369,7 +414,7 @@ from source; it does not restrict normal SDK users compiling against a published sysroot/libc++ artifact. After the script exits 0, the resolver verifies every path in -`outputs.{libs,headers,pkgconfig}` exists under `$WASM_POSIX_DEP_OUT_DIR`. +`outputs.{libs,headers,pkgconfig,files}` exists under `$WASM_POSIX_DEP_OUT_DIR`. A missing output fails the build (and the temp dir is cleaned up, so a retry starts clean). diff --git a/docs/porting-guide.md b/docs/porting-guide.md index 2f081d93ab..e52c87c30a 100644 --- a/docs/porting-guide.md +++ b/docs/porting-guide.md @@ -544,7 +544,7 @@ library dep) for canonical references; the schema reference is in kind = "program" # or "library" or "source" name = "myprog" version = "1.2.3" -kernel_abi = 18 # current ABI_VERSION; required for packages with a [build] block +kernel_abi = 39 # current ABI_VERSION; required for packages with a [build] block depends_on = ["zlib@1.3.1"] # transitive deps the resolver will pull first [source] diff --git a/docs/posix-status.md b/docs/posix-status.md index dbec136eef..1f31926b6f 100644 --- a/docs/posix-status.md +++ b/docs/posix-status.md @@ -20,7 +20,10 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve **Key properties:** - **Single kernel instance** with a `ProcessTable` mapping PIDs to `Process` structs - **Process workers** communicate with the kernel via channel IPC — each process/thread has a channel region in shared memory, and the kernel services syscalls one at a time from the JS event loop -- **Cross-process shared state** (open file descriptions, pipes, locks, IPC) is managed directly by the kernel — no extra SharedArrayBuffer structures needed per feature +- **Cross-process shared state** uses kernel-global or host-coordinated + backings where implemented. Pipes, locks, IPC objects, sockets, and selected + stateful descriptors retain one backing across fork; ordinary regular-file + OFD seek positions and status flags are still copied per process. - **Serialized syscall execution** — the kernel handles one syscall at a time, which provides natural atomicity for operations like O_APPEND writes and PIPE_BUF-sized pipe writes - **Signal delivery** across processes is direct — the kernel can write to any process's pending signal mask @@ -41,30 +44,30 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `close()` | Partial | Ref-counted OFD cleanup. Host handle closed when last ref dropped. Releases all fcntl advisory locks on the file (POSIX-compliant). EINTR not yet handled. | | `read()` | Partial | Host-delegated for files. Pipe/socket reads from kernel ring buffer with blocking when empty (EINTR on signal). Short reads permitted. O_NONBLOCK returns EAGAIN. | | `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. | +| `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. For regular files and memfds, RLIMIT_FSIZE is calculated once per logical operation: a crossing operation returns the prefix that fits without a signal; a later non-empty operation with no room fails with EFBIG and generates thread-directed SIGXFSZ. | +| `pwrite()` | Partial | Host-delegated via seek-write-restore. Not atomic (single-threaded safe only). Rejects pipes/sockets with ESPIPE. Uses the same operation-wide RLIMIT_FSIZE rule as write without changing the OFD cursor. | +| `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. | -| `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity guaranteed by serialized syscalls in the kernel. O_NONBLOCK enforced (EAGAIN). Cross-process pipes work naturally via shared OFD table after fork. | +| `pipe()` | Partial | Kernel-space ring buffer (64KB). PIPE_BUF=4096 atomicity is guaranteed by serialized kernel syscalls. O_NONBLOCK returns EAGAIN. Forked descriptors retain the same global pipe backing even though their per-process OFD metadata is copied. | | `pipe2()` | Full | Like pipe with O_NONBLOCK and O_CLOEXEC flag support. | | `readv()` | Full | Scatter read. Iterates over iovec array calling sys_read for each buffer. Stops on short read or EOF. | -| `writev()` | Full | Gather write. Iterates over iovec array calling sys_write for each buffer. Stops on short write. | +| `writev()` | Full | Gather write. Enforces aggregate count and RLIMIT_FSIZE once across the full iovec operation, including host scratch-buffer decomposition, then stops on a short underlying write. | | `fstat()` | Partial | Host-delegated for regular files. Pipe returns S_IFIFO | 0o600. Full struct stat populated. | -| `ftruncate()` | Partial | Host-delegated for regular files with write access. Validates length >= 0. Rejects non-regular fds. | +| `ftruncate()` | Partial | Host-delegated for regular files, with in-kernel memfd support. Requires write access, validates length >= 0, rejects non-regular fds, and enforces RLIMIT_FSIZE before changing either backing. | | `fsync()` | Partial | Host-delegated for regular files. Rejects non-regular fds (pipes, sockets). | | `fdatasync()` | Partial | Alias for fsync(). No metadata distinction in Wasm environment. | | `truncate()` | Partial | Path-based. Opens file O_WRONLY, calls ftruncate, closes. | | `fchmod()` | Partial | Regular files and directories update VFS metadata. Rejects pipes/sockets. Node host-backed files never receive native mode changes after creation. | -| `fchown()` | Partial | Regular files and directories update VFS metadata. Rejects pipes/sockets. Node host-backed files never receive native ownership changes. | +| `fchown()` | Partial | Regular files and directories update VFS metadata. `(uid_t)-1` and `(gid_t)-1` preserve the corresponding current ID without bypassing descriptor, authorization, or backend-error checks. Actual changes remain restricted to effective uid 0; the current owner may issue the raw `(-1, -1)` no-change request. Kernel-owned non-file descriptors still accept the call as a no-op, and Node host-backed ownership changes stay virtual. | | `preadv()` | Full | Scatter-gather read at offset. Iterates iovec entries calling pread for each. Stops on short read or EOF. | -| `pwritev()` | Full | Scatter-gather write at offset. Iterates iovec entries calling pwrite for each. Stops on short write. | +| `pwritev()` | Full | Scatter-gather write at offset. Enforces aggregate count and RLIMIT_FSIZE once across the full iovec operation, then stops on a short underlying write. | | `preadv2()` / `pwritev2()` | Partial | Delegates to preadv/pwritev. Extra flags parameter ignored. | -| `sendfile()` | Full | Emulated with read+write loop (no zero-copy in Wasm). Supports offset parameter for positioned read from input fd. | -| `fallocate()` | Stub | Returns 0 (no-op). File space managed by host. | -| `copy_file_range()` | Full | Emulated with pread+pwrite loop. Supports optional offsets for both input and output fds. Cross-fd copy between regular files, pipes, and sockets. | -| `splice()` | Full | Emulated with pread+pwrite loop. Supports pipe-to-file, file-to-pipe, and pipe-to-pipe transfers with optional offsets. | +| `sendfile()` | Full | Emulated with read+write loop (no zero-copy in Wasm). Supports an optional positioned input offset. The output RLIMIT_FSIZE budget is fixed before input is consumed, so a limit-induced short transfer advances the input only by the returned count. | +| `fallocate()` | Partial | Mode 0 extends through ftruncate when needed, including RLIMIT_FSIZE enforcement; allocation guarantees and nonzero modes are not implemented. | +| `copy_file_range()` | Full | Emulated with pread+pwrite loop. Supports optional offsets for both input and output fds. The output RLIMIT_FSIZE budget is fixed before input is consumed. | +| `splice()` | Full | Emulated through the copy loop with optional offsets. The output RLIMIT_FSIZE budget is fixed before input is consumed. | | `tee()` / `vmsplice()` | Stub | Returns ENOSYS. | | `readahead()` | Stub | Returns 0 (no-op advisory). | | `fstatat()` | Full | AT_FDCWD delegates to stat/lstat. AT_SYMLINK_NOFOLLOW supported. Real dirfd supported via stored OFD paths. | @@ -74,7 +77,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `renameat()` | Full | Both dirfds supported (AT_FDCWD, absolute, or real dirfd). | | `faccessat()` | Full | AT_FDCWD delegates to access(). Absolute paths and real dirfd supported. | | `fchmodat()` | Full | AT_FDCWD delegates to chmod(). AT_SYMLINK_NOFOLLOW accepted. Real dirfd supported. | -| `fchownat()` | Full | AT_FDCWD delegates to chown(). Real dirfd supported. | +| `fchownat()` | Partial | AT_FDCWD and real dirfds are supported, including unchanged-ID sentinels. The final symlink is followed by default and changed directly with `AT_SYMLINK_NOFOLLOW`. Unsupported flags, including `AT_EMPTY_PATH`, return EINVAL. Actual ownership changes remain restricted to effective uid 0. | | `linkat()` | Full | Both dirfds supported (AT_FDCWD, absolute, or real dirfd). | | `symlinkat()` | Full | Target stored as-is. Linkpath resolved via dirfd. Real dirfd supported. | | `readlinkat()` | Full | AT_FDCWD delegates to readlink(). Real dirfd supported. | @@ -99,16 +102,17 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | Function | Status | Notes | |----------|--------|-------| -| `fork()` | Full | The kernel serializes full process state (FD/OFD tables, signals, environment, CWD, rlimits, brk, terminal), and the host spawns a child Worker with copied Memory. Child resumes execution at the `fork()` call site with return value 0 via the `wpk_fork_*` instrumentation injected by `wasm-fork-instrument` (Phase 7; see [fork-instrumentation.md](fork-instrumentation.md)) — the call stack, local variables, and `__tls_base`/`__stack_pointer` are preserved across the boundary. Fork from pthread workers is supported by routing the child through the saved pthread entry function and the calling thread's fork buffer. Cross-process pipes, signals, and waitpid all functional. | -| `exec()` | Full | Kernel-initiated via SYS_EXECVE (syscall 211). Host `handleExec` reads path/argv/envp from process memory, calls `onExec` callback. Replaces process image. Preserves PID, open fds (closes CLOEXEC), environment, CWD, signal mask. **Resets** the program break (POSIX-correct); host then re-installs the new program's `__heap_base` via `kernel_set_brk_base`. | -| `waitpid()` | Full | Kernel-internal: blocks parent until child exits (WNOHANG supported). Reaps zombie processes. Supports pid>0 (specific child), pid=-1 (any child), pid=0 (same pgid), pid<-1 (specific pgid). Returns normal-exit status with WIFEXITED/WEXITSTATUS and signal-death status with WIFSIGNALED/WTERMSIG. | -| `exit()` / `_exit()` | Full | Closes all fds and dir streams, releases all fcntl locks, sets ProcessState::Exited. SIGCHLD delivered to parent. Zombie state maintained until reaped by waitpid. | +| `fork()` | Partial | The kernel copies process state and the host starts a child Worker with copied Memory. Initial launch mirrors the environment into kernel-owned process state; fork copies that metadata while instrumented rewind preserves the live libc `environ` in copied Memory, and `execve()` replaces both from its supplied `envp`. `wasm-fork-instrument` resumes the child at the call site with preserved stack locals and mutable globals. Main-thread and pthread fork are supported, as is the documented direct main-to-one-side-module path; nested/opaque cross-side callbacks and fork from a pthread inside a side module remain unsupported. Pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, procfs snapshots, and shared mappings retain their existing backings; signal and wait lifecycle state is copied/coordinated by the kernel. Ordinary regular-file OFD seek positions/status flags are still copied rather than shared. See [fork-instrumentation.md](fork-instrumentation.md) and the known OFD gap below. | +| `exec()` | Partial | Kernel-initiated via SYS_EXECVE (syscall 211). The host preflights the module, ABI, replacement memory, caller, deferred file actions, and a 4 MiB combined argv/environment representation (strings, terminators, and pointer entries) before replacing the image in place; individual strings are limited to 64 KiB and oversize returns `E2BIG` without truncation. Preserves PID, non-CLOEXEC fds and their exact kernel-backed object state, new argv/envp (including an explicitly empty environment), CWD, the calling pthread's signal mask and directed queue, terminal queues, and `alarm()`/`ITIMER_REAL`; closes directory streams, deletes `timer_create()` timers, publishes and detaches old mappings, terminates sibling threads, and resets the program break before installing the new `__heap_base`. File mappings retain a stable writeback handle even after their original fd closes. Remaining gaps: POSIX message-queue descriptors are not process-owned and therefore cannot yet be closed on exec; epoll registrations track numeric fds rather than OFD identity, so close/dup and same-number replacement cases are incomplete; and main-thread-directed signals share the process-pending queue and therefore cannot be distinguished from process-directed signals when a worker pthread execs. | +| `wait()` / `waitpid()` / `wait4()` / `waitid()` | Partial | Rust-owned child status covers stop, continue, normal exit, and signal death. New status replaces older unconsumed status; `waitid(WNOWAIT)` preserves the current record. `WNOHANG`, `WUNTRACED`/`WSTOPPED`, `WEXITED`, and `WCONTINUED` are supported, as are specific-PID, any-child, same-process-group, and specific-process-group selection. Stop/continue reports do not reap; consuming exit status does. `wait4()` returns the zero-filled resource-usage wire record described under `getrusage()`. Remaining gap: a blocked `pid == 0` / `P_PGID,id == 0` wait currently re-evaluates the caller's process group on each host retry instead of freezing it at call entry. | +| `exit()` / `_exit()` | Full | Closes all fds and dir streams, releases locks and mapping/backing ownership, and retains the low eight status bits. Normal codes 128–255 remain distinct from signal termination, which is stored separately. SIGCHLD is delivered to the parent and zombie state remains until `waitpid()` reaps it. | | `getpid()` | Full | Returns pid from Process struct. | | `getppid()` | Full | Returns ppid (0 for init process). | | `getuid()` / `geteuid()` | Full | Simulated; defaults to uid=0 (root). Configurable via setuid/seteuid. | | `getgid()` / `getegid()` | Full | Simulated; defaults to gid=0 (root). Configurable via setgid/setegid. | | `setuid()` / `seteuid()` | Full | POSIX semantics (no saved-set-uid tracked). As root: setuid sets both uid and euid; seteuid sets any euid. Non-root: setuid only to own uid; seteuid only to own ruid. Returns EPERM otherwise. | | `setgid()` / `setegid()` | Full | POSIX semantics mirroring setuid — gated on euid==0 for privileged changes. Returns EPERM for non-root trying to change to a foreign gid. | +| `getpriority()` / `setpriority()` | Partial | Stores a per-process nice value; WebAssembly has no host CPU scheduler to apply it to. Linux-compatible `/proc//stat` exposes scheduler priority in field 18 and nice in field 19. Procfs metadata operations reject missing or reaped PID scopes. | | `getpgrp()` | Full | Returns process group ID (simulated, defaults to pid). | | `setpgid()` | Partial | Sets process group ID. pid=0 means self. pgid=0 means use target pid. Only supports setting own pgid; other processes return ESRCH. | | `getsid()` | Full | Returns session ID (simulated, defaults to pid). pid=0 means self. | @@ -117,11 +121,11 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `gettid()` | Partial | Returns pid for the main thread and the host-bound worker TID for pthread workers. Remaining limitation: this is Linux-compatible rather than POSIX-standard, and not all signal/thread APIs consume TID-specific state yet. | | `set_tid_address()` | Partial | Returns the calling TID and stores the clear-TID pointer for thread exit notification. Host thread cleanup writes 0 and futex-wakes the address for normal pthread exit and forced cleanup paths. Robust-list handling remains deferred. | | `set_robust_list()` | Stub | No-op. Robust futex list tracking deferred until threading is fully tested. | -| `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, FUTEX_WAKE_OP implemented. Main-process WAIT returns EAGAIN so the host retries via Atomics.waitAsync. Thread workers use direct Atomics.wait. | -| `execve()` | Full | Delegates to kernel_execve. Replaces process image. | -| `execveat()` | Full | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`. Supports AT_EMPTY_PATH for `fexecve()`. Relative paths resolved against process CWD. | -| `fork()` (syscall) | Full | Glue traps to the kernel via channel IPC. Kernel serializes state, host callback spawns child Worker. Returns child pid to parent, 0 to child. Continuation across the fork boundary uses `wasm-fork-instrument`'s `wpk_fork_*` exports. | -| `vfork()` | Full | Alias for fork(). | +| `futex()` | Partial | FUTEX_WAIT, FUTEX_WAKE, FUTEX_REQUEUE, FUTEX_CMP_REQUEUE, and FUTEX_WAKE_OP operate on one process's shared memory. Main-process WAIT uses host `Atomics.waitAsync`; pthread workers use direct `Atomics.wait`. Separate processes have separate `SharedArrayBuffer` objects, so these operations do not wake or synchronize a peer PID even when the futex word lies in a host-coordinated MAP_SHARED mapping. | +| `execve()` | Partial | Delegates to the in-place `exec()` path and has the same remaining descriptor/signal/mapping limitations described above. | +| `execveat()` | Partial | SYS_EXECVEAT (386). Resolves fd path via `kernel_get_fd_path`, supports AT_EMPTY_PATH for `fexecve()`, and resolves relative paths against process CWD; otherwise has the same remaining `exec()` limitations. | +| `fork()` (syscall) | Partial | Glue traps through channel IPC; the kernel copies process state, the host starts a child Worker, and `wasm-fork-instrument` replays the supported call stack so parent/child receive the POSIX return values. The side-module and ordinary-OFD limitations in the main `fork()` row still apply. | +| `vfork()` | Partial | Alias for `fork()` and therefore has the same continuation/OFD limitations; it does not provide distinct vfork address-space semantics. | | `posix_spawn()` | Full | **Non-forking implementation** (this kernel's invention; no Linux equivalent). Glue issues `SYS_SPAWN` (500) with a marshalled blob (argv + envp + file actions + spawn attrs). Host parses the blob, calls `kernel_spawn_process` to allocate a child pid + build the child Process descriptor, then invokes `onSpawn` to launch a fresh Worker. No fork, no `wpk_fork_*` rewind, no exec replay. Supports POSIX_SPAWN_SETSID / SETPGROUP / SETSIGMASK / SETSIGDEF and FDOP_OPEN / CLOSE / DUP2 / CHDIR / FCHDIR. SIG_IGN dispositions persist across the implicit exec; custom handlers reset to SIG_DFL (POSIX exec semantics). Regression-guarded: `kernel_get_fork_count` exposes a per-process counter the test suite asserts is unchanged across SYS_SPAWN. See `docs/plans/2026-05-04-non-forking-posix-spawn-design.md`. | | `posix_spawnp()` | Full | PATH search lives in libc (`libc/musl-overlay/src/process/wasm32posix/posix_spawnp.c`); resolves the absolute path then delegates to `posix_spawn()`. Empty PATH entries treated as `.`; defers EACCES per `__execvpe` policy. | | `clone()` | Partial | Thread-style clone (CLONE_VM\|CLONE_THREAD) supported. The kernel allocates the TID, and the host spawns a thread Worker sharing the parent's Memory. Normal pthread return, pthread_exit, and cancellation cleanup remain per-thread and wake join/clear-TID waiters; uncaught fatal Wasm traps in a pthread worker terminate the whole process with signal-style wait status. | @@ -137,7 +141,7 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `acct()` | Stub | Returns ENOSYS. | | `reboot()` | Stub | Returns EPERM. | | `swapon()` / `swapoff()` | Stub | Returns EPERM. | -| `syslog()` | Full | SYS_SYSLOG (kernel log) returns 0. libc syslog() works via AF_UNIX SOCK_DGRAM bit-bucket pattern — connect/write to `/dev/log` silently discards. | +| `syslog()` | Partial | `SYS_SYSLOG` (kernel-log control) returns 0. Kandelo does not currently provide a `/dev/log` datagram receiver; AF_UNIX datagram connect therefore exposes the missing endpoint instead of silently discarding messages. | | `capget()` / `capset()` | Stub | Returns EPERM. No capabilities model. | | `vhangup()` | Stub | Returns EPERM. | | `sethostname()` / `setdomainname()` | Stub | Returns EPERM. | @@ -153,33 +157,44 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve |----------|--------|-------| | `kill()` | Partial | Marks signal as pending. sig=0 validity check. Cross-process delivery via host_kill import and ProcessManager.deliverSignal(). Pending signals delivered at syscall boundaries. POSIX EPERM enforced: unprivileged processes cannot signal a target whose real/effective uid does not match their own. A virtual init (pid 1, uid 0) is auto-registered so `kill(1, ...)` resolves; target 4 in compromising-xfails.md. | | `signal()` | Full | Legacy API. Returns previous handler. Wraps sigaction() semantics. SIGKILL/SIGSTOP immutable. | -| `sigaction()` | Full | Sets handler disposition (SIG_DFL, SIG_IGN, or function pointer) plus sa_flags and sa_mask. SIGKILL/SIGSTOP immutable. SA_RESTART supported: blocking read/write/recv/poll auto-restart instead of returning EINTR. SA_SIGINFO: flags passed to host so handler is called as `handler(signum, siginfo_ptr, ucontext_ptr)`. SA_NOCLDWAIT auto-reaps children and suppresses SIGCHLD. SA_NOCLDSTOP stored but not yet acted upon (no job control). SIG_IGN discards pending signals; SIG_DFL discards pending signals for signals whose default action is "ignore" (e.g., SIGCHLD). **Note:** Programs must be linked with `--table-base=3 --export-table` so the host can dispatch handlers from the user program's function table (indices 0/1 reserved for SIG_DFL/SIG_IGN, index 2 reserved for `__main_void`). | +| `sigaction()` | Partial | Sets handler disposition (SIG_DFL, SIG_IGN, or function pointer) plus sa_flags and sa_mask. SIGKILL/SIGSTOP immutable. SA_RESTART is honored by the existing blocking read/write/recv/poll paths and by host-deferred waits. SA_SIGINFO calls `handler(signum, siginfo_ptr, ucontext_ptr)` with pointer-width-correct layout, but host-generated SIGCHLD currently lacks the exact child pid/CLD code/status metadata. SA_NOCLDWAIT auto-reaps children and suppresses SIGCHLD. SA_NOCLDSTOP suppresses stop/continue SIGCHLD notification without discarding waitable status. SIG_IGN discards pending signals; SIG_DFL discards pending signals for signals whose default action is "ignore" (e.g., SIGCHLD). **Note:** Programs must be linked with `--table-base=3 --export-table` so the host can dispatch handlers from the user program's function table (indices 0/1 reserved for SIG_DFL/SIG_IGN, index 2 reserved for `__main_void`). | | `sigprocmask()` | Full | Block/unblock/setmask operations on 64-bit signal mask. SIGKILL and SIGSTOP cannot be blocked per POSIX. | | `sigsuspend()` | Full | Atomically replaces signal mask and blocks until deliverable signal arrives. Uses SharedArrayBuffer + Atomics.wait/notify for cross-thread wake. Always returns EINTR. | | `pause()` | Full | Suspends until a signal is delivered. Delegates to sigsuspend with current mask. Always returns EINTR. | | `raise()` | Full | Equivalent to kill(getpid(), sig). | -| `alarm()` | Full | Sets SIGALRM timer via host setTimeout. Returns previous remaining seconds. alarm(0) cancels. Not inherited by fork, canceled by exec. | +| `alarm()` | Full | Sets SIGALRM timer via host setTimeout. Returns previous remaining seconds. alarm(0) cancels. Not inherited by fork; preserved across exec. | | `setitimer()` | Full | ITIMER_REAL: sets alarm deadline + interval via host_set_alarm. ITIMER_VIRTUAL/ITIMER_PROF: no-op (no CPU time tracking). Fixes musl's alarm() which internally calls setitimer. | | `getitimer()` | Full | ITIMER_REAL: returns stored interval + remaining time from deadline. ITIMER_VIRTUAL/ITIMER_PROF: returns zero. | -| `sigtimedwait()` | Full | Checks pending signals in mask, dequeues lowest. Returns si_signo, si_code (SI_USER/SI_QUEUE), and si_value in siginfo_t. Polls with 1ms sleep on timeout. Returns EAGAIN on timeout. | +| `sigtimedwait()` | Partial | Checks the calling thread's directed and process-shared pending signals, dequeues the lowest match, and returns signal-specific `siginfo_t` metadata, including timer ID and overrun for `SI_TIMER`. Host-originated signals wake matching blocked thread channels immediately; finite retries preserve the original deadline and timeout expiry returns EAGAIN. Remaining gap: a caught signal outside the waited set does not yet interrupt the wait with `EINTR`. | | `sigqueue()` / `rt_sigqueueinfo()` | Full | Sends signal with si_value. RT signals (32-63) are queued with FIFO ordering; standard signals (1-31) coalesced. si_code set to SI_QUEUE (-1). | | `rt_sigreturn()` | Stub | Returns 0. Signal trampoline handled by host. | -| `signalfd()` / `signalfd4()` | Full | Creates a file descriptor for accepting signals. Reads return `signalfd_siginfo` structs (128 bytes) for pending signals matching the mask. Supports poll() for readiness. | +| `signalfd()` / `signalfd4()` | Full | Creates a descriptor whose mask is held in a refcounted kernel-global backing, shared across inherited descriptors and retained by non-CLOEXEC exec. Reads return 128-byte `signalfd_siginfo` records for matching pending signals; poll readiness is supported. | ## Memory Management | Function | Status | Notes | |----------|--------|-------| -| `mmap()` | Partial | Anonymous, file-backed MAP_PRIVATE, and file-backed MAP_SHARED. Page-aligned (64KB Wasm pages). MAP_FIXED supported. Host populates file-backed regions via pread; MAP_SHARED regions are flushed on msync via pwrite. | -| `msync()` | Full | Flushes MAP_SHARED regions back to the file via pwrite. No-op for MAP_PRIVATE (correct per POSIX). | -| `shm_open()` / `shm_unlink()` | Full | musl maps to `/dev/shm/` paths; host rewrites to tmpdir on macOS. Works with MAP_SHARED mmap. | -| `munmap()` | Full | Removes tracked region. Page-aligned address required. Partial munmap supported: front trim, back trim, and middle split. | +| `mmap()` | Partial | Anonymous, regular-file `MAP_PRIVATE`, and regular-file `MAP_SHARED` mappings use 64 KiB Wasm pages. `MAP_FIXED` replaces the complete rounded page range; a usable non-fixed hint is rounded down and preferred without replacing occupied mappings. Anonymous and regular-file shared mappings inherited by fork converge at syscall boundaries through a host-owned backing, not immediately on direct loads/stores. Regular-file sharing requires stable backend device/inode identity and retains the original open host handle, so mapping remains valid after the guest fd closes or its pathname is unlinked or renamed. In-kernel memfd and identity-less backends (currently OPFS) return `ENOTSUP` for `MAP_SHARED`; `MAP_PRIVATE` still works. Bytes beyond EOF are zero-filled/dropped instead of delivering Linux `SIGBUS`, and external host writers are not detected. | +| `msync()` | Partial | Publishes the calling process's changed bytes and writes dirty regular-file pages through the stable mapping handle; `MAP_PRIVATE` remains private. Writeback is clipped to the current file size and reports `EIO` on a coherence/writeback failure. `MS_SYNC` versus `MS_ASYNC` scheduling is not distinguished, and visibility between processes is not immediate between syscalls. | +| `shm_open()` / `shm_unlink()` | Partial | musl maps names to `/dev/shm/` files (with the Node/macOS host rewrite). The resulting regular-file `MAP_SHARED` mappings work across fork and independent fds at syscall boundaries, subject to the file-mapping, futex, and EOF limitations in this section. | +| `munmap()` | Full | Removes tracked regions. The address must be 64KB-page-aligned; the length is rounded up to the next Wasm page. Partial munmap supports front trim, back trim, and middle split, including matching host-side MAP_SHARED tracking. | +| `mremap()` | Partial | Supports page-rounded shrink, in-place growth, and `MREMAP_MAYMOVE`; other flags are rejected. The host moves/resizes matching anonymous and file-shared tracking and preloads a file expansion before the destructive kernel step. Wasm cannot revoke the old bytes after a move, just as `munmap()` cannot make later direct access fault. | | `brk()` / `sbrk()` | Partial | Kernel-managed program break. Initial break installed by host from the program's `__heap_base` export via `kernel_set_brk_base` (16MB hardcoded fallback for binaries without `__heap_base`). Growing and shrinking supported. Inherited on `fork`; **reset** on `exec` and re-installed from the new program's `__heap_base` (POSIX-correct). | -| `mprotect()` | Partial | Returns success (no-op). Wasm linear memory has no page-level protection, so protection changes are silently accepted. | -| `memfd_create()` | Full | In-kernel anonymous file backed by Vec. MFD_CLOEXEC and MFD_ALLOW_SEALING flags. Supports read, write, lseek, ftruncate, fstat, mmap. | +| `mprotect()` | Partial | Wasm cannot enforce page protection on direct memory access. A successful write upgrade validates that an overlapping file-shared mapping has a lifetime-stable writable handle and marks the whole tracked interval writeback-eligible; that eligibility remains monotonic after a later downgrade. Other protection effects are not enforced. | +| `memfd_create()` | Full | In-kernel anonymous file backed by a refcounted global object whose contents and cursor survive fork/spawn and non-CLOEXEC exec. MFD_CLOEXEC and MFD_ALLOW_SEALING flags are supported. `MAP_PRIVATE` population works; `MAP_SHARED` deliberately returns `ENOTSUP` until memfd has a coherent mapping bridge. | ## Directory Operations +Pathname syscalls walk components in the kernel's global namespace before +dispatching the resolved path to rootfs, a host-backed mount, procfs, devfs, or +an AF_UNIX pathname. This preserves `.` and `..` until the preceding component +has been checked, follows relative and absolute symlinks across mount +boundaries, enforces the 40-link limit, and gives a trailing slash its required +directory semantics. Cwd and directory OFDs still retain canonical pathnames +rather than stable directory identities: rename/unlink followed by recreation +can therefore make `getcwd()`, `fchdir()`, or a dirfd-relative operation refer +to a different directory than the original OFD. + | Function | Status | Notes | |----------|--------|-------| | `opendir()` | Partial | Host-delegated via DirStream table. Entry-at-a-time iteration. Stores resolved path for rewinddir. | @@ -190,13 +205,13 @@ Kandelo uses a single kernel Wasm instance that holds a `ProcessTable` and serve | `seekdir()` | Full | Rewinds and skips entries to reach target position. | | `mkdir()` | Partial | Host-delegated. Relative paths resolved via kernel cwd. umask applied to mode. | | `rmdir()` | Partial | Host-delegated. Relative paths resolved via kernel cwd. | -| `chdir()` / `getcwd()` | Partial | Kernel-maintained cwd. chdir validates via host_stat that target is S_IFDIR. getcwd returns ERANGE if buffer too small. | +| `chdir()` / `getcwd()` | Partial | `chdir()` resolves components and symlinks across mounts, verifies search permissions and a directory target, and stores the canonical physical pathname. Initial process cwd uses the same validation after child credentials are installed. `getcwd()` validates that spelling and returns ERANGE if the buffer is too small, but cwd remains pathname-backed rather than a stable directory identity after rename/unlink. | | `link()` / `unlink()` | Partial | Host-delegated. Relative paths resolved via kernel cwd. | | `rename()` | Partial | Host-delegated. Both paths resolved via kernel cwd. | -| `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. | -| `chmod()` / `chown()` | Partial | VFS metadata updates. Node host-backed files receive native mode only at file/directory creation; later mode changes and all ownership changes stay virtual. Browser memory-backed mounts store metadata in the VFS. | -| `access()` | Partial | Host-delegated. Checks real filesystem permissions. | -| `realpath()` | Full | Resolves path against cwd, normalizes `.`/`..`, resolves symlinks via iterative lstat/readlink (ELOOP after 40 resolutions), verifies existence. | +| `stat()` / `lstat()` | Partial | Host-delegated. stat follows symlinks, lstat does not. Registered AF_UNIX pathname sockets preserve the backing VFS inode's uid, gid, permissions, timestamps, and link count while reporting `S_IFSOCK`. | +| `chmod()` / `chown()` / `lchown()` | Partial | VFS metadata updates. `chown()` follows the final symlink; `lchown()` changes the link itself, including dangling links. Ownership calls preserve either unchanged-ID sentinel, validate the selected object and authorization before delegating even same-value requests, restrict actual changes to effective uid 0, and permit raw `(-1, -1)` only to the selected object's owner or root. Owner group changes and supplementary-group authorization remain incomplete. Node host-backed files receive native mode only at file/directory creation; later mode changes and all ownership changes stay virtual. Browser memory-backed mounts store metadata in the VFS. OPFS has neither symlinks nor ownership metadata, so its existing ownership operations are no-ops. | +| `access()` | Partial | Resolves the pathname component-wise and checks traversal plus target permissions with real credentials. `faccessat(..., AT_EACCESS)` selects effective credentials. Supplementary-group authorization remains incomplete. | +| `realpath()` | Full | Uses the global component walker against cwd, including mount crossings and relative or absolute symlinks; `missing/..` fails instead of being collapsed lexically, trailing slash requires a directory, and more than 40 symlinks returns ELOOP. | | `symlink()` / `readlink()` | Partial | Host-delegated. Symlink target stored as-is, linkpath resolved. | | `sync()` / `syncfs()` | Stub | Returns 0 (no-op). Filesystem sync managed by host. | | `sync_file_range()` | Stub | Returns 0 (no-op). | @@ -227,20 +242,21 @@ shortcuts. | Function | Status | Notes | |----------|--------|-------| -| `socket()` | Partial | AF_UNIX and AF_INET supported for SOCK_STREAM and SOCK_DGRAM. SOCK_NONBLOCK and SOCK_CLOEXEC flags handled. AF_INET SOCK_DGRAM is implemented as a kernel-level datagram socket for loopback/virtual routes; external raw UDP is not exposed directly to userspace. | +| `socket()` | Partial | AF_UNIX, AF_INET, and AF_INET6 support SOCK_STREAM and SOCK_DGRAM. SOCK_NONBLOCK and SOCK_CLOEXEC flags are handled. AF_INET6 is limited to local `::`/`::1` routes; external and virtual-network IPv6 transports are not implemented. AF_INET SOCK_DGRAM uses kernel queues for loopback and a HostIO backend for routed virtual IPv4; external raw UDP is not exposed directly to userspace. | | `socketpair()` | Full | AF_UNIX SOCK_STREAM. Bidirectional ring buffers (64KB each). Returns pre-connected pair. | -| `bind()` | Partial | AF_UNIX paths, AF_INET TCP host-backed bind/listen, and AF_INET UDP in-kernel bind for INADDR_ANY, loopback, and broadcast addresses. The browser local virtual-network backend supports AF_INET TCP/UDP binds between attached Kandelo machines. UDP ephemeral ports, getsockname, bind conflicts, and SO_REUSEADDR conflict cases are covered by Sortix UDP tests. | -| `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. Datagram listen rejects as unsupported. AF_UNIX stream listen remains EOPNOTSUPP. | -| `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend and returns connected sockets. Datagram accept rejects as unsupported. SOCK_NONBLOCK and SOCK_CLOEXEC flags on accept4. | -| `connect()` | Partial | AF_UNIX SOCK_DGRAM connects to a bit-bucket pattern. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. UDP loopback and local virtual routes are supported; external raw UDP routes return ENETUNREACH without a HostIO UDP proxy/backend. | -| `send()` / `recv()` | Partial | Unix domain streams, AF_INET TCP, and connected AF_INET UDP. TCP send/recv works over Node external TCP and the local virtual-network backend. UDP preserves datagram boundaries and supports MSG_PEEK/MSG_DONTWAIT through recvfrom. MSG_NOSIGNAL is honored for stream/broken-pipe paths. | -| `sendto()` / `recvfrom()` | Partial | AF_INET UDP loopback and local virtual-network send/receive, connected and unconnected sendto, source address reporting, and connected receive filtering are implemented. External raw UDP routes return ENETUNREACH and are narrowly xfailed in the Sortix UDP suite. | -| `setsockopt()` / `getsockopt()` | Partial | SOL_SOCKET: SO_TYPE, SO_DOMAIN, SO_ERROR, SO_ACCEPTCONN, SO_RCVBUF, SO_SNDBUF readable; SO_REUSEADDR affects UDP bind conflicts; SO_KEEPALIVE, SO_LINGER, SO_RCVTIMEO, SO_SNDTIMEO, SO_BROADCAST accepted/stored. IPPROTO_TCP: TCP_NODELAY stored. | -| `shutdown()` | Partial | SHUT_RD, SHUT_WR, SHUT_RDWR for stream sockets and UDP readiness/error behavior. UDP write shutdown returns EPIPE on datagram send; read shutdown is EOF-like for recv/poll. | -| `select()` | Partial | Wrapper around poll(). Converts fd_set bitmasks to pollfd array. Timeout supported via polling loop. | +| `bind()` | Partial | AF_UNIX pathname and Linux abstract-namespace addresses, AF_INET TCP host-backed bind/listen, and AF_INET UDP in-kernel bind for INADDR_ANY, loopback, and broadcast addresses. AF_UNIX pathname bind resolves to a canonical namespace path and creates a VFS inode with mode `0777 & ~umask`; `stat`/`lstat`/`fstatat`, `chmod`, and `chown` share that inode metadata, and the socket metadata remains until unlink after the final close. Ordinary pathname rename rekeys the socket registry, including replacement of a stale destination registration; hard-link identity is not tracked. Abstract addresses create no VFS inode and become reusable after their final inherited owner closes. AF_INET6 accepts `::` and `::1`; stream and datagram binds use machine-wide conflict tables, and a non-`IPV6_V6ONLY` wildcard stream bind also reserves the IPv4 wildcard port. The browser local virtual-network backend supports AF_INET TCP/UDP binds between attached Kandelo machines, not IPv6. | +| `listen()` | Partial | AF_INET TCP delegates to the active HostIO networking backend, including Node `net` and the browser local virtual-network backend. AF_UNIX stream listen is implemented. AF_INET, AF_INET6, and AF_UNIX listeners inherited by fork share one accept queue, so any surviving pre-fork worker can accept each connection once. AF_INET6 `::`/`::1` loopback listeners support same- and cross-process connections; external and virtual IPv6 listeners do not. Datagram listen rejects as unsupported. | +| `accept()` / `accept4()` | Partial | AF_INET TCP delegates to the active HostIO networking backend; AF_UNIX and AF_INET6 loopback streams return connected sockets from the shared kernel queue. A dual-stack IPv6 listener reports IPv4 peers as IPv4-mapped `sockaddr_in6`. Linux-style accept does not inherit O_NONBLOCK; accept4 applies SOCK_NONBLOCK and SOCK_CLOEXEC explicitly and rejects other flags before consuming a pending connection. Datagram accept rejects as unsupported. | +| `connect()` | Partial | AF_UNIX streams support same- and cross-process pathname or abstract-namespace listeners; pathname lookup uses the same canonical component walker as bind, including cross-process retries. AF_UNIX datagrams deliver to a registered peer only within the same process; a missing, wrong-type, or cross-process peer returns ECONNREFUSED until machine-wide datagram routing exists. AF_INET TCP is host-backed and works over Node external TCP or the browser local virtual-network backend. For an external non-blocking TCP handshake, the first pending call reports EINPROGRESS, a repeat while it remains pending reports EALREADY, and poll reports writable when completion or failure can be collected through SO_ERROR; blocking callers wait through the same host connection. AF_INET UDP connect stores the peer, auto-binds an ephemeral local port when needed, filters receives to the connected peer, and supports AF_UNSPEC unconnect. AF_INET6 streams support same- and cross-process `::1`; AF_INET6 datagrams are process-local and report `IPV6_V6ONLY=1` because dual-stack datagram routing is not implemented. Non-loopback IPv6 fails with EADDRNOTAVAIL for streams and ENETUNREACH for datagrams. External raw UDP also returns ENETUNREACH without another HostIO transport. | +| `send()` / `recv()` | Partial | Unix domain streams and datagrams, AF_INET/AF_INET6 TCP streams, and connected AF_INET/AF_INET6 UDP preserve their socket-family addressing and datagram boundaries. TCP send/recv works over Node external TCP and the local virtual-network backend. Datagram MSG_PEEK and MSG_DONTWAIT are handled through recvfrom. Normal TCP close drains queued bytes before FIN and EOF; no transport invents a fixed post-FIN write count. A send rejected by a closed/reset stream returns EPIPE and raises SIGPIPE, while direct host/virtual handles may preserve ECONNRESET; accepted pipe-bridged resets currently surface as EOF/EPIPE. MSG_NOSIGNAL suppresses SIGPIPE without changing the errno. | +| `sendto()` / `recvfrom()` | Partial | AF_INET, AF_INET6, and AF_UNIX datagrams support connected and unconnected send, receive queues, and connected-peer filtering. IPv4/IPv6 return sender addresses; AF_UNIX currently returns only the family. IPv4 limited-broadcast sends to `255.255.255.255` require `SO_BROADCAST` and fail with `EACCES` without it; enabling the option passes that permission gate, after which the send reaches the active routing/backend boundary. Kandelo does not itself model broadcast delivery. On AF_INET, AF_INET6, and AF_UNIX datagrams, Linux's input `MSG_TRUNC` extension returns the full datagram length while copying at most the caller's buffer; ordinary consume/`MSG_PEEK` behavior is unchanged. IPv4/IPv6 UDP receive queues hold 128 datagrams and drop a new arrival once full, preserving the accepted queue's order; `SO_RCVBUF` requests do not size that fixed queue, and `getsockopt` reports the fixed default capacity. AF_UNIX uses the same bound but preserves reliable delivery: a full queue blocks a blocking send through host retry and returns EAGAIN for `O_NONBLOCK`/`MSG_DONTWAIT`; capacity, association, shutdown, close, and pathname changes wake blocked sends and writable readiness waits to observe capacity or the new immediate error. In-kernel IPv4/IPv6 loopback, AF_UNIX datagram, and IPv4 multicast delivery currently reaches sockets in the sender's process only; machine-wide cross-process datagram routing remains unimplemented. Fork preserves kernel-local bind reservations and lookup ownership, but it does not yet share or transfer a host-backed UDP registration. The `10.88.*` LocalVirtualNetwork path can route IPv4 datagrams between attached Kandelo machines through HostIO for the process that registered the endpoint. IPv4 multicast supports interface selection, loop suppression, membership, and source filtering only; IPv6 multicast and external raw UDP are not implemented. | +| `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_RCVTIMEO`/`SO_SNDTIMEO` accept musl's wasm32 time64 option numbers (66/67) and wasm64 long64 numbers (20/21), canonicalizing both to the same stored timeout state; `struct timeval` is 16 bytes on both ABIs. `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 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. | @@ -253,7 +269,7 @@ shortcuts. |----------|--------|-------| | `time()` | Full | Wrapper around clock_gettime(CLOCK_REALTIME). Returns seconds since epoch. | | `gettimeofday()` | Full | Wrapper around clock_gettime(CLOCK_REALTIME). Returns (sec, usec) pair. | -| `clock_gettime()` | Full | Host-delegated. CLOCK_REALTIME and CLOCK_MONOTONIC supported. Node.js uses Date.now() and process.hrtime.bigint(). | +| `clock_gettime()` | Partial | Host-delegated. `CLOCK_REALTIME` and `CLOCK_MONOTONIC` are supported. Linux `CLOCK_REALTIME_COARSE` and `CLOCK_MONOTONIC_COARSE` requests use the corresponding host clock as an equivalent fallback because Kandelo hosts do not expose separate coarse sources. `CLOCK_BOOTTIME` is a monotonic-equivalent fallback because Kandelo hosts do not expose suspend accounting. The named process/thread CPU clocks currently report elapsed monotonic time rather than authoritative CPU usage; musl's encoded per-thread clock IDs returned by `pthread_getcpuclockid()` are not yet recognized and return `EINVAL`. Linux-style encoded process CPU clock IDs must be negative; malformed positive encodings are rejected with `EINVAL` (which musl's `clock_getcpuclockid()` maps to `ESRCH`). Node.js uses `Date.now()` and `process.hrtime.bigint()`; browsers use `Date.now()` and `performance.now()`. | | `nanosleep()` | Partial | Host-delegated. Node.js uses Atomics.wait with timeout. Browser support requires a worker context that can block with Atomics.wait. Validates tv_sec >= 0 and tv_nsec < 1e9. | | `usleep()` | Full | Converts microseconds to sec+nsec, delegates to host_nanosleep. | | `clock_settime()` | Stub | Returns EPERM. Cannot set system clock from Wasm. | @@ -274,23 +290,23 @@ shortcuts. | `sched_get_priority_min()` | Stub | Returns 0. | | `sched_rr_get_interval()` | Stub | Writes 10ms timespec. | | `sched_setaffinity()` | Stub | Returns 0 (no-op). | -| `sched_getaffinity()` | Stub | Sets bit 0 in cpuset (1 CPU). Returns cpuset size. | +| `sched_getaffinity()` | Stub | Linux-specific one-CPU compatibility surface. Running or stopped workers and process leaders that have not been reaped (including zombie leaders) report a fixed four-byte CPU-0 mask; reaped leaders, dead workers, and absent tasks return `ESRCH`. The raw syscall requires a size of at least four bytes aligned to four, writes and returns exactly four bytes, and leaves a larger raw buffer's tail untouched. Musl's public wrapper zero-fills that tail and returns 0. Exact leader PIDs take precedence over Kandelo's per-process worker TID records, whose numeric IDs can still collide across processes. | | `sched_yield()` | Stub | Returns 0 (no-op, single-threaded). | ## Event/Notification | Function | Status | Notes | |----------|--------|-------| -| `eventfd()` / `eventfd2()` | Full | Per-process u64 counter. read returns 8-byte counter value (blocks/EAGAIN if zero). write adds to counter. EFD_SEMAPHORE: read returns 1, decrements by 1. EFD_NONBLOCK, EFD_CLOEXEC supported. poll reports POLLIN when counter > 0, POLLOUT when writable. | -| `timerfd_create()` | Full | Creates timer fd with CLOCK_REALTIME or CLOCK_MONOTONIC. TFD_NONBLOCK and TFD_CLOEXEC flags. | -| `timerfd_settime()` / `timerfd_gettime()` | Full | Arms/disarms timer with interval and initial expiration. TFD_TIMER_ABSTIME for absolute time. read returns 8-byte expiration count. poll reports POLLIN when expired. | +| `eventfd()` / `eventfd2()` | Full | A refcounted kernel-global u64 counter is shared by inherited descriptors across fork/spawn and survives exec unless CLOEXEC. read returns the counter (or 1 for EFD_SEMAPHORE); write adds to it. EFD_NONBLOCK/EFD_CLOEXEC and poll readiness are supported. | +| `timerfd_create()` | Full | Creates a refcounted kernel-global timerfd backing with CLOCK_REALTIME or CLOCK_MONOTONIC. Inherited descriptors observe the same timer and expiration count; non-CLOEXEC state survives exec. TFD_NONBLOCK and TFD_CLOEXEC are supported. | +| `timerfd_settime()` / `timerfd_gettime()` | Full | Arms/disarms the shared timerfd backing with interval and initial expiration. TFD_TIMER_ABSTIME is supported; read returns the shared expiration count and poll reports POLLIN when expired. | | `inotify_init()` / `inotify_init1()` | Stub | Returns ENOSYS. | | `inotify_add_watch()` / `inotify_rm_watch()` | Stub | Returns EBADF. | | `fanotify_init()` / `fanotify_mark()` | Stub | Returns ENOSYS. | -| `timer_create()` | Full | CLOCK_REALTIME and CLOCK_MONOTONIC. SIGEV_SIGNAL delivery with si_value. Per-process timer table (max 32). | -| `timer_settime()` / `timer_gettime()` | Full | Absolute (TIMER_ABSTIME) and relative time. Interval timers with automatic rearming. Host setTimeout-based delivery. | -| `timer_getoverrun()` | Full | Tracks overrun count when signal is still pending at next interval fire. Reset on successful signal delivery. | -| `timer_delete()` | Full | Cancels timer and removes from per-process table. | +| `timer_create()` | Partial | Supports `CLOCK_REALTIME`, `CLOCK_MONOTONIC`, and monotonic-equivalent `CLOCK_BOOTTIME` with `SIGEV_SIGNAL`, `SIGEV_NONE`, Linux `SIGEV_THREAD_ID`, and POSIX `SIGEV_THREAD` through musl's exact-thread helper. Expirations preserve `SI_TIMER`, `si_value`, timer ID, and overrun metadata on Node and browser hosts. The fixed kernel wire carries `sival_int`; on wasm64, direct signal notifications cannot carry a wider `sival_ptr`, while `SIGEV_THREAD` retains the native-width callback value locally in the helper. | +| `timer_settime()` / `timer_gettime()` | Partial | Absolute (`TIMER_ABSTIME`) and relative timers and automatic interval rearming use host timers with millisecond granularity. `timer_gettime()` and `timer_settime()`'s old-value result currently report the last configured value rather than decreasing remaining time. | +| `timer_getoverrun()` | Full | Tracks overruns per timer while its notification remains pending and reports the count associated with the most recently accepted notification. | +| `timer_delete()` | Full | Cancels the host timer, removes its queued notification before slot reuse, and removes it from the per-process table. | ## IPC (System V & POSIX Message Queues) @@ -298,7 +314,7 @@ shortcuts. |----------|--------|-------| | `msgget()` / `msgsnd()` / `msgrcv()` / `msgctl()` | Full | Host-side SysV message queues via SharedIpcTable. Key-based creation, blocking send/recv with message types, IPC_STAT/IPC_SET/IPC_RMID control. | | `semget()` / `semop()` / `semctl()` / `semtimedop()` | Full | Host-side SysV semaphore sets. Atomic multi-semaphore operations, SEM_UNDO support, IPC_STAT/SETVAL/GETVAL/SETALL/GETALL. | -| `shmget()` / `shmat()` / `shmdt()` / `shmctl()` | Full | Host-side SysV shared memory segments. Attach/detach via kernel mmap, IPC_STAT/IPC_RMID control. Cross-process sharing via SharedIpcTable. | +| `shmget()` / `shmat()` / `shmdt()` / `shmctl()` | Partial | Host-side SysV shared-memory segments support IPC_STAT/IPC_RMID, fork inheritance, and exact attach/detach accounting. Separate process memories merge changed attachment bytes and import peer changes at syscall boundaries. Direct stores are not immediately visible and cross-process futex synchronization over an attachment is unsupported. | | `ftok()` | Full | Standard ftok algorithm using stat inode + proj_id. | | `mq_open()` / `mq_close()` / `mq_unlink()` | Full | Host-side POSIX message queues via PosixMqueueTable. O_CREAT/O_EXCL/O_RDONLY/O_WRONLY/O_RDWR/O_NONBLOCK. Descriptor range 0x40000000+. | | `mq_timedsend()` / `mq_timedreceive()` | Full | Priority-ordered message delivery. Blocking with timeout support. O_NONBLOCK returns EAGAIN. | @@ -339,13 +355,13 @@ shortcuts. | `/dev/stdin` | Full | Alias for `/dev/fd/0`. | | `/dev/stdout` | Full | Alias for `/dev/fd/1`. | | `/dev/stderr` | Full | Alias for `/dev/fd/2`. | -| `/dev/tty` | Full | Controlling terminal. Opens the session's controlling PTY slave (ENXIO if none). | +| `/dev/tty` | Partial | Uses the first open PTY-slave OFD as the current controlling-terminal heuristic. When none is open, it currently falls back to fd 0 rather than returning ENXIO; `pathconf()` follows that same OFD selection and therefore does not advertise terminal variables for the captured, pipe-backed case. | | `/dev/ptmx` | Full | PTY master multiplexer. `open()` allocates a new PTY pair, returns master fd. | | `/dev/pts/*` | Full | PTY slave devices. `posix_openpt()` + `grantpt()` + `unlockpt()` + `ptsname()`. Full line discipline, canonical/raw mode, OPOST/ONLCR, 16 terminal ioctls. | -| `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`close`/`exit`/`exec` clean up. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | -| `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership released on `close`/`exec`/`exit`. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | -| `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership released on close-of-last-fd / `execve` / `exit`; the ring is flushed at the same time. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | -| `/dev/shm/*` | Not yet | POSIX shared memory — requires cross-process SharedArrayBuffer. | +| `/dev/fb0` | Full | Linux fbdev framebuffer. Single-open (`EBUSY` for second opener). 640×400 BGRA32 packed-pixel. ioctls: `FBIOGET_VSCREENINFO`, `FBIOGET_FSCREENINFO`, `FBIOPAN_DISPLAY` (no-op success), `FBIOPUT_VSCREENINFO` (validates geometry). `mmap` returns a region in process memory and notifies the host (`bind_framebuffer` callback) so the browser canvas can mirror pixels. `munmap`/`exit`/`exec` discard the image mapping; a surviving fd retains device ownership across exec. Ownership is released after both the final fd and any live mapping are gone, since a mapping remains valid after `close()`. Linux-VT keyboard ioctls (`KDGKBTYPE`/`KDGKBMODE`/`KDSKBMODE`) accepted with sensible defaults so fbDOOM-style software works unmodified. | +| `/dev/input/mice` | Full | Linux `mousedev` PS/2 mouse stream. Single-open (`EBUSY` for second pid). 3-byte packets: byte0 button bits + sign/overflow flags, bytes 1..2 signed dx/dy with positive-up dy. Host pushes events via `kernel_inject_mouse_event(dx, dy, buttons)`; the kernel buffers up to 4096 packets (whole-packet drop on overflow). `read()` drains queued bytes; returns `EAGAIN` when empty. `poll()` reports `POLLIN` only when bytes are queued. Ownership and queued packets survive exec with a non-CLOEXEC fd; last close or exit releases and clears them. No IMPS/2 wheel protocol, no `evdev`/`/dev/input/eventN`. | +| `/dev/dsp` | Full (write-only) | OSS-style PCM audio sink. Single-open (`EBUSY` for second pid). `write()` accepts interleaved 16-bit-LE PCM and buffers it in a 256 KiB ring; the host drains via the `kernel_drain_audio` wasm export and feeds a Web Audio `AudioContext`. ioctls: `SNDCTL_DSP_RESET`, `SNDCTL_DSP_SYNC`, `SNDCTL_DSP_SPEED` (clamp 4000–192000 Hz), `SNDCTL_DSP_STEREO` / `SNDCTL_DSP_CHANNELS` (1 or 2), `SNDCTL_DSP_SETFMT` (only `AFMT_S16_LE`), `SNDCTL_DSP_GETFMTS`, `SNDCTL_DSP_SETFRAGMENT` (accept-and-acknowledge). On overflow drops the *oldest whole frame* — never tears L/R alignment. Ownership and queued samples survive exec with a non-CLOEXEC fd; last close or exit releases and flushes them. `read()` returns 0 (EOF-like). `poll()` reports `POLLOUT` always, never `POLLIN`. No record path, no `mmap`-based zero-copy; DOOM's mixer is in user space. | +| `/dev/shm/*` | Partial | POSIX shm objects are regular files used by `shm_open()`. Stable-identity backends support host-coordinated `MAP_SHARED` across processes at syscall boundaries; this is not immediate shared linear memory and does not make process-shared futexes work. | All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, deterministic inode numbers, and `st_dev=5`. Path interception in kernel before host delegation — no host filesystem changes needed. `access()` returns OK for all virtual devices. @@ -365,11 +381,11 @@ All virtual devices return synthetic `stat()` with `S_IFCHR | 0666`, determinist | `sysconf()` | Partial | Handles _SC_CHILD_MAX, _SC_CLK_TCK=100, _SC_PAGE_SIZE=65536, _SC_OPEN_MAX=1024, _SC_NPROCESSORS_ONLN=1, _SC_NPROCESSORS_CONF=1, _SC_MONOTONIC_CLOCK=1, _SC_THREAD_SAFE_FUNCTIONS=1, plus 100+ POSIX.1-2024 constants via musl overlay. Unknown names return EINVAL. | | `umask()` | Full | Set file creation mask, returns previous mask. Default 0o022. Applied in open() and mkdir(). Masked to 0o777. | | `getrlimit()` | Full | Returns (soft, hard) resource limits. Defaults: NOFILE=(1024,4096), STACK=(8MB,infinity), others infinity. | -| `setrlimit()` | Partial | Sets resource limits. Validates soft <= hard. RLIMIT_NOFILE enforced via FdTable max_fds sync. RLIMIT_FSIZE enforced in write()/ftruncate() (EFBIG + SIGXFSZ). | -| `getrusage()` | Partial | Returns zeroed rusage struct (144 bytes). RUSAGE_SELF and RUSAGE_CHILDREN supported. No actual resource tracking in Wasm. | -| `pathconf()` | Full | Returns POSIX compile-time constants: _PC_NAME_MAX=255, _PC_PATH_MAX=4096, _PC_PIPE_BUF=4096, _PC_LINK_MAX=14, etc. | -| `fpathconf()` | Full | Same as pathconf() but validates fd exists first. Returns EBADF for invalid fd. | -| `getsockname()` | Partial | Returns stored local address for AF_UNIX, AF_INET TCP, and AF_INET UDP. UDP ephemeral ports, loopback/INADDR_ANY binds, and accepted INADDR_ANY connect outcomes are covered by Sortix UDP tests. External-route local address selection remains unsupported without a HostIO networking backend. | +| `setrlimit()` | Partial | Sets resource limits and validates soft <= hard. RLIMIT_NOFILE updates the fd-table ceiling. RLIMIT_FSIZE covers regular-file and memfd write/pwrite, vectored, transfer, truncate, and mode-0 fallocate paths with partial-to-limit results and thread-directed SIGXFSZ only when no byte can be written. Other resource limits remain advisory or unsupported. | +| `getrusage()` | Partial | Returns the 144-byte kernel wire record used by musl: two timevals and 14 counters, all zero because Wasm hosts do not expose the required accounting. Musl converts that record into the target's public `struct rusage` layout. RUSAGE_SELF and RUSAGE_CHILDREN are supported. | +| `pathconf()` | Partial | Resolves the real namespace path and queries the selected backend. The common resolver enforces byte-based `_PC_NAME_MAX=255`, `_PC_PATH_MAX=4096` for caller and symlink-substituted pathnames (including the terminating NUL), and `_PC_NO_TRUNC=1`; a relative pathname is not charged for the process's internal absolute CWD prefix. `_PC_CHOWN_RESTRICTED=1` reflects the kernel authorization gate. Regular files report thread-backed AIO support. Symlink and timestamp answers reflect the backend (HostFS/MemoryFS timestamps are millisecond-granularity; OPFS reports indeterminate). `_PC_FILESIZEBITS` is currently indeterminate because the selected backend does not yet prove a file-offset bit width. Invalid names and unsupported file-type associations return EINVAL; valid indeterminate or unsupported options return -1 without changing errno. | +| `fpathconf()` | Partial | Uses the live OFD/backend identity rather than a remembered pathname, so renamed or unlinked open files remain queryable. Invalid fds return EBADF. Kernel pipes report `_PC_PIPE_BUF=4096`; host-backed captured stdio leaves it indeterminate because atomicity through that boundary is not yet proven. `_PC_FILESIZEBITS` remains indeterminate until the selected live backend can prove a file-offset bit width. Terminal buffers are currently unbounded, `_PC_VDISABLE=0`, and socket maximum buffering is indeterminate. | +| `getsockname()` | Partial | Returns stored local addresses for AF_UNIX and AF_INET/AF_INET6 stream or datagram sockets. UDP ephemeral ports, loopback/INADDR_ANY binds, and accepted INADDR_ANY connect outcomes are covered by Sortix UDP tests. External-route local address selection remains unsupported without a HostIO networking backend. | | `getpeername()` | Full | Returns stored peer address for connected sockets. Returns ENOTCONN for unconnected. | --- @@ -382,16 +398,16 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| -| **fork siblings have independent OFD seek positions** | fork / fd | POSIX.1-2017 §2.4.1 requires that fork children's fds refer to the **same** open file description as the parent — meaning seek pointer, status flags, and pending I/O state are SHARED. Our `OfdTable` lives inside `Process`, so fork deep-clones it; each process has independent OFD copies thereafter. A program that forks then both processes append to the same fd expecting interleaved output (cooperative log writers, parallel `make` job-server pipes, etc.) will silently produce garbled output. Tracked as a redesign item in `docs/future-improvements.md` ("Per-process OFD storage breaks POSIX fork OFD-sharing") — fix is to move OFDs to a kernel-global table with `Process` holding `FdTable`. | +| **fork siblings have independent ordinary-file OFD metadata** | fork / fd | POSIX requires the parent and child descriptors to refer to one open file description. Kandelo now retains exact global backings for pipes, sockets, PTYs, eventfd/timerfd/signalfd, memfd, and procfs snapshots, but it still deep-copies ordinary regular-file seek positions, status flags, and owners with the per-process `OfdTable`. Workloads that coordinate through one inherited regular fd can therefore observe divergent offsets or flags. The global-OFD redesign remains tracked in [future-improvements.md](future-improvements.md). | ### High — Missing features that affect common programs | Gap | Subsystem | Description | |-----|-----------|-------------| | **EINTR partially implemented** | all | read, write, recv, poll, select return EINTR when a signal is pending during a blocking wait. close() and other non-blocking syscalls do not check. Tied to signal handler invocation gap. | -| ~~**PIPE_BUF atomicity not enforced**~~ | pipe | **Resolved.** Syscalls are serialized through the kernel, so concurrent writes ≤ PIPE_BUF cannot interleave. | +| **PIPE_BUF guarantee at host-backed stdio boundary** | pipe / host | In-kernel pipes guarantee atomic writes through 4096 bytes and report that value from `fpathconf()`. Captured stdio uses host-backed pipe OFDs; its callback/native-write boundary has not been proven all-or-nothing through the compile-time `PIPE_BUF` value, so `fpathconf()` reports the limit as indeterminate. Do not treat the global `` promise as fully reconciled until that boundary is enforced or stdio is modeled differently. | | ~~**O_APPEND not atomic**~~ | write | **Resolved.** Syscalls are serialized through the kernel, so seek-to-end + write cannot be interrupted by another process. | -| ~~**sigaction() missing sa_flags**~~ | signals | **Resolved.** SA_RESTART supported (auto-restart blocking syscalls). sa_flags and sa_mask stored. SA_SIGINFO handler delivery with siginfo_t. SA_NOCLDWAIT auto-reaps children. SA_NOCLDSTOP accepted but not acted upon (no job control). | +| ~~**sigaction() missing sa_flags**~~ | signals | **Resolved.** SA_RESTART supported (auto-restart blocking syscalls). sa_flags and sa_mask stored. SA_SIGINFO handler delivery with siginfo_t. SA_NOCLDWAIT auto-reaps children. SA_NOCLDSTOP suppresses stop/continue SIGCHLD notification while preserving waitable status. | | ~~**No signal queuing**~~ | signals | **Resolved.** RT signals (32-63) are now queued in a VecDeque; standard signals (1-31) remain coalesced per POSIX. | | ~~**`*at()` functions with real dirfd**~~ | filesystem | **Resolved.** All *at() syscalls now support real dirfd via stored OFD paths. | | ~~**No seekdir/telldir/rewinddir**~~ | directory | **Resolved.** DirStream now tracks path and position. rewinddir/telldir/seekdir implemented. | @@ -400,16 +416,16 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Description | |-----|-----------|-------------| -| **RLIMIT_FSIZE partial enforcement** | rlimits | write() and ftruncate() check FSIZE limit (EFBIG + SIGXFSZ). truncate() delegates to ftruncate so also enforced. | +| ~~**RLIMIT_FSIZE partial enforcement**~~ | rlimits | **Resolved for implemented write and size-changing operations.** Scalar, vectored, host-chunked, transfer, regular-file, memfd, truncate, and mode-0 fallocate paths share one operation-boundary contract. Pipes, terminals, sockets, and other non-size-bearing objects remain unaffected. | | **setpgid() self-only** | process | Only supports setting own pgid. Setting another process's pgid returns ESRCH. | | ~~**realpath() no symlink resolution**~~ | filesystem | **Resolved.** Now resolves symlinks via iterative lstat/readlink with ELOOP after 40 resolutions. | -| **Socket options partially no-op** | socket | SO_REUSEADDR affects UDP bind conflicts. SO_KEEPALIVE, SO_LINGER, SO_BROADCAST, SO_RCVTIMEO, SO_SNDTIMEO, TCP_NODELAY are accepted/stored but have limited or no effect on data transfer. | +| **Socket options partially no-op** | socket | `SO_REUSEADDR` affects UDP bind conflicts, and `SO_BROADCAST` enforces the IPv4 limited-broadcast permission gate, but actual broadcast delivery remains unavailable. `SO_RCVBUF` and `SO_SNDBUF` are accepted/stored without resizing queues or pipe buffers; `getsockopt()` reports the fixed default. `SO_KEEPALIVE`, `SO_RCVTIMEO`, `SO_SNDTIMEO`, and `TCP_NODELAY` remain accepted/stored with limited or no data-path effect. The timeout options recognize both wasm32 time64 numbers (66/67) and wasm64 long64 numbers (20/21); that ABI parity does not broaden their documented data-path effect. Enabled `SO_LINGER` is rejected rather than stored as a no-op. | | **POLLERR partial** | I/O multiplex | poll() reports UDP pending socket errors and stream shutdown/error cases. Some edge cases remain implementation-defined. | | **pread/pwrite not multi-process safe** | I/O | Uses save/seek/read/restore pattern — safe only when no other process shares the OFD, but races with shared OFDs across processes. | | ~~**brk not inherited on fork**~~ | memory | **Resolved.** Program break serialized/deserialized in fork state. (`exec` reset is intentional per POSIX; host re-installs from new program's `__heap_base`.) | | ~~**VMIN/VTIME not interpreted**~~ | terminal | **Partially resolved.** VMIN/VTIME values accessible via TerminalState methods. Full VMIN/VTIME read semantics for raw mode are approximated. | | ~~**ICANON no line buffering**~~ | terminal | **Resolved.** ICANON mode now buffers input with line editing: VERASE (backspace), VKILL (^U), VEOF (^D). ICRNL/INLCR/IGNCR input processing and ECHO/ECHOE/ECHOK/ECHONL echo handling. | -| ~~**No job control**~~ | terminal | **Partially resolved.** tcgetpgrp()/tcsetpgrp() implemented via TIOCGPGRP/TIOCSPGRP ioctls. SIGTTIN/SIGTTOU not yet generated. | +| ~~**No job control**~~ | terminal | **Partially resolved.** tcgetpgrp()/tcsetpgrp() are implemented via TIOCGPGRP/TIOCSPGRP. SIGSTOP, SIGTSTP, SIGTTIN, and SIGTTOU have process-wide stop semantics, and SIGCONT resumes regardless of mask or disposition. Terminal background-I/O generation of SIGTTIN/SIGTTOU is not yet implemented. | | ~~**readdir() "." and ".." entries**~~ | directory | **Resolved.** Kernel now synthesizes "." and ".." entries before host entries. | | **No ENFILE** | fd | Only per-process EMFILE limit exists. No system-wide fd limit tracking. | @@ -418,8 +434,9 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego | Gap | Subsystem | Reason | |-----|-----------|--------| | **mprotect() is a no-op** | memory | Returns success but does not enforce. Wasm linear memory has no page-level protection. | -| **No cross-process MAP_SHARED** | memory | MAP_SHARED works within one process address space (file-backed, with msync writeback). Cross-process shared memory would require SharedArrayBuffer coordination. | +| **No immediate cross-process shared-memory or futex semantics** | memory | Anonymous, SysV, and stable-identity regular-file shared mappings now merge and refresh across processes at syscall boundaries. They are not one physical linear memory: direct stores remain private until a syscall, a peer spinning only on loads sees no update, and futex WAIT/WAKE targets only the caller's process `SharedArrayBuffer`. Process-shared pthread locks and PHP opcache's normal shared-memory locking model therefore remain unsupported. The PHP package rejects its normal SHM mode and supports only explicitly configured `opcache.file_cache_only=1`; otherwise FPM workers would observe divergent cache and lock state. memfd `MAP_SHARED`, current OPFS file mappings, Linux `SIGBUS` on access beyond EOF, and detection of external host writes also remain gaps. | | **External raw UDP routes** | socket | AF_INET SOCK_DGRAM has POSIX-style in-kernel loopback/virtual semantics, but browsers cannot expose raw UDP and Node raw UDP is not yet wired behind HostIO. Non-loopback UDP routes currently return ENETUNREACH unless a future host backend/proxy handles them. | +| **Stop is cooperative at a Wasm boundary** | process / signals | The kernel records stopped state immediately and the shared host withholds every exact channel completion until SIGCONT. This suspends code at syscall boundaries in both Node and browser, but a process executing CPU-bound Wasm without reaching a syscall cannot be stopped at an arbitrary instruction by current WebAssembly execution APIs. | | **Setuid/setgid enforcement** | process | Single-user Wasm environment; privilege checks simulated only. | | **Permission checks** | filesystem | Delegated to host. Kernel does not independently verify file permissions. | | **getrusage() zeroed** | sysinfo | No actual resource tracking available in Wasm. Returns zero-filled struct. | @@ -431,19 +448,18 @@ Systematic audit of all subsystems against POSIX specifications. Gaps are catego - `clone()` — CLONE_VM|CLONE_THREAD: kernel allocates TID, host spawns thread Worker sharing parent's Memory. TLS initialization via `__wasm_thread_init` export. - `gettid()` — returns actual TID for threads, pid for main thread - `set_tid_address()` — stores tidptr; kernel writes 0 + futex-wakes on thread exit (CLONE_CHILD_CLEARTID) -- `futex()` — WAIT/WAKE/REQUEUE/CMP_REQUEUE/WAKE_OP implemented; main-process WAIT returns EAGAIN (host retries via Atomics.waitAsync), thread workers use direct Atomics.wait +- `futex()` — WAIT/WAKE/REQUEUE/CMP_REQUEUE/WAKE_OP are implemented within one process; cross-process waits/wakes remain unsupported even over a coordinated shared mapping - `pthread_create` — works via clone(). Basic pthreads tested (mutex, join). Normal thread return, `pthread_exit`, and cancellation cleanup are per-thread; uncaught fatal Wasm traps in a pthread worker are process-fatal and visible to parent `waitpid()` as signal termination. Cancellation remains limited; see the Wasm-inherent gaps below. **Hard / Architectural:** -- Cross-process MAP_SHARED mmap (would need SharedArrayBuffer coordination between workers) +- Immediate cross-process MAP_SHARED visibility and process-shared futexes (would require one addressable shared backing or an equivalent wake protocol across process workers) - True async poll/select (replace polling loop with host-based event notification) -- SA_NOCLDWAIT / SA_NOCLDSTOP (stored but not acted upon; waitpid is host-delegated) - Full VMIN/VTIME raw mode semantics (timer-based timeout) **Shared-kernel advantages (already free):** - O_APPEND atomicity (serialized syscalls) - PIPE_BUF atomicity (serialized syscalls) -- Cross-process eventfd/pipe/epoll sharing via shared OFD table +- Cross-process pipe/socket/PTY and eventfd/timerfd/signalfd/memfd/procfs backing identity across inherited descriptors - Signal delivery across processes is direct --- @@ -462,7 +478,7 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in | `fcntl()` locking | Kernel-coordinated via atomic ops | postMessage round-trip, higher latency | | `pipe()` blocking read | Blocks worker until data available | Not supported without a worker/blocking bridge | | `nanosleep()` | `Atomics.wait()` with timeout | Not supported without a worker/blocking bridge | -| Multi-process shared memory | Direct shared linear memory | Not supported; would need serialization | +| Multi-process shared memory | Host-coordinated merge/import at syscall boundaries; not direct shared pages or cross-process futexes | Not supported without the worker/channel SAB runtime | ### Browser vs Node.js @@ -512,11 +528,11 @@ These features require SharedArrayBuffer (and cross-origin isolation headers in - host_kill Wasm import with cross-process routing in sys_kill - DeliverSignalMessage protocol and ProcessManager.deliverSignal() - KillRequestMessage: worker → host → target worker signal routing -13e. **Phase 13e (Complete):** Exec -- Exec state serialization: CLOEXEC fd filtering, signal handler reset, pending preservation -- kernel_get_exec_state / kernel_init_from_exec Wasm exports +13e. **Phase 13e (historical milestone complete; current conformance remains Partial):** Exec +- In-place centralized exec: CLOEXEC filtering, signal disposition reset, pending-queue preservation +- Legacy kernel_get_exec_state / kernel_init_from_exec Wasm exports (scheduled for ABI cleanup) - host_exec Wasm import and sys_execve syscall -- Worker re-initialization: new kernel instance with exec state in same worker +- Worker re-initialization against the continuing centralized kernel Process - ProcessManager.exec() for host-initiated exec 14. **POSIX Compliance Batch 4 (Complete):** ~20 syscalls — tkill, sigpending, getpgid, setreuid/setregid, sysinfo, times, lchown, waitid, plus glue-only stubs 15. **POSIX Compliance Batch 5 (Complete):** ~100+ syscalls @@ -555,7 +571,7 @@ Target use case: hosting PHP-WASM (as used by WordPress Playground) on this kern |-----|-----------|-------------|------------| | ~~`connect()` for AF_INET~~ | socket | **Done.** Host-delegated TCP networking. bind/listen/accept/connect/send/recv all functional. Node.js backend uses `net` module; browser backend uses fetch for HTTP. | ~~Hard~~ | | ~~`getaddrinfo()` / `gethostbyname()`~~ | DNS | **Done.** Host-delegated via `host_getaddrinfo` import. Returns AF_INET sockaddr_in. `/etc/hosts` is served from the canonical `rootfs.vfs` mount at `/` for localhost resolution. | ~~Medium~~ | -| ~~`setsockopt()` expansion~~ | socket | **Done.** SO_KEEPALIVE, TCP_NODELAY, SO_REUSEADDR, SO_LINGER, and many more stored. | ~~Easy~~ | +| ~~`setsockopt()` expansion~~ | socket | **Done.** SO_KEEPALIVE, TCP_NODELAY, SO_REUSEADDR, disabled SO_LINGER state, and many more are represented; enabled SO_LINGER remains explicitly unsupported. | ~~Easy~~ | | ~~Async socket polling bridge~~ | socket | **Done.** poll/select/epoll all work with socket fds. The kernel checks readiness inline. | ~~Medium~~ | ### Phase C — Process management (enables wp-cli, Composer, PHPUnit) @@ -599,8 +615,14 @@ These PHP needs are well-handled by the current kernel: - Memory: anonymous mmap, munmap, brk - Multi-process: fork (kernel syscall), exec (host-initiated), waitpid (kernel syscall) - Networking: AF_INET TCP (connect, bind, listen, accept, send, recv), getaddrinfo -- Dynamic linking: dlopen, dlsym, dlclose, dlerror (Wasm dylink) -- POSIX timers: timer_create, timer_settime, timer_gettime, timer_delete +- Dynamic linking: dlopen, dlsym, dlclose, dlerror (Wasm dylink on the process + worker). Pthread workers cannot share the process's Wasm table/tag graph, so + pthread `dlopen` fails and pthread `fork` after a process dlopen returns + `ENOTSUP`. +- POSIX timers: `SIGEV_SIGNAL`, `SIGEV_NONE`, and `SIGEV_THREAD` timer creation, + timer_settime, timer_gettime, overrun reporting, and deletion. Timer timing + remains host-scheduled at millisecond granularity, and direct wasm64 + signal-notification values are limited to the fixed-width `sival_int` wire. - System info: uname, sysconf, umask, getrlimit/setrlimit --- @@ -619,7 +641,10 @@ These require features fundamentally unavailable in the Wasm architecture: - **Wasm FP exceptions (110 math tests):** WebAssembly has no floating-point exception flags (`fenv.h`). All `fe*` math tests fail. `long double` variants pass because they use software fp128. - **No pthread_cancel:** Wasm has no async cancellation mechanism or cancel-point assembly. `pthread_create` works; `pthread_cancel` does not. -- **No dlopen/TLS:** `tls_get_new-dtv_dso` requires loading a shared library with TLS at runtime. +- **No musl DTV expansion for arbitrary DSOs:** `tls_get_new-dtv_dso` requires + native-style per-thread dynamic TLS-vector growth. Kandelo can replay the + fixed TLS reservation of a Wasm side module across process `fork`, but that + does not implement musl's general pthread DTV contract. ### Linker Requirements for Signal Handlers diff --git a/docs/profiling.md b/docs/profiling.md index 52a644fa28..ee87e2e775 100644 --- a/docs/profiling.md +++ b/docs/profiling.md @@ -118,6 +118,27 @@ npx tsx benchmarks/run.ts --host=browser --suite=process-lifecycle --rounds=5 Results are saved as JSON in `benchmarks/results/`. +Each result records the selected benchmark artifact paths, sizes, and SHA-256 +digests for that host's measurements. The application evidence covers +the PHP/opcache inputs, Node WordPress source/config/router, browser WordPress +and MariaDB VFS images, and each Node MariaDB architecture's server, client, +and bootstrap SQL. The WordPress source-tree digest follows source symlinks but +excludes runtime-owned `wp-content/database` state and `wp-content/debug.log`. +Resolver-selected paths are retained alongside the logical artifact names; +browser VFS evidence reflects the public asset that the benchmark page selects +first. Kernel fingerprints use the same policy-aware binary resolver as each +host. Node rootfs evidence records which of the runtime's `rootfs.vfs` then +`programs/rootfs.vfs` fallback requests won, and is required only for the +syscall/process suites that boot that default image. Browser benchmarks do not +record or require the default rootfs because they boot generated empty or app +images. +Node static benchmark Wasm inputs are required only by the syscall or process +suite that consumes them. The browser benchmark page imports its seven micro +Wasm URLs at module load, so every runnable browser suite requires all seven; +`exec-bench.wasm` remains Node-only. +After printing the artifact report, the runner stops before workloads when a +required, selected input is missing. + ### Available Suites #### syscall-io @@ -170,6 +191,17 @@ Runs PHP 8.4 with a full WordPress 6.7 installation. Two measurements: cold CLI | `cli_require_ms` | ms | `php -r "require 'wp-load.php'"` — process start to exit | | `http_first_response_ms` | ms | Start PHP built-in server, time to first HTTP response | +Each Node measurement starts from the WordPress setup state: the benchmark +removes and recreates `wp-content/database`, removes `wp-content/debug.log`, and +does the same cleanup after the measurement. When the OPcache side module is +available and `NO_OPCACHE` is not `1`, the measurement uses +`opcache.file_cache_only=1` with timestamp validation disabled. Each suite +round creates a private cache root under `benchmarks/results/`, and the CLI and +HTTP measurements each receive a separate empty cache directory that is reset +before and after timing. The run-owned cache root is removed when the round +finishes, so compiled scripts cannot carry across metrics, rounds, concurrent +benchmark processes, or worktrees. + **Prerequisites:** | Component | Path | Build command | @@ -257,7 +289,9 @@ npx tsx benchmarks/run.ts --host=browser --rounds=3 npx tsx benchmarks/compare.ts benchmarks/results/.json benchmarks/results/.json ``` -Suites that cannot find their binaries will skip gracefully and report no metrics. If a suite skips, build its prerequisites (see above) before drawing conclusions about performance impact. +When a required binary is missing, the runner prints the artifact report and +fails before starting workloads. Build the missing prerequisites (see above) +before drawing conclusions about performance impact. ### Comparing Results diff --git a/docs/sdk-guide.md b/docs/sdk-guide.md index d4d8e7bad9..a18448a68d 100644 --- a/docs/sdk-guide.md +++ b/docs/sdk-guide.md @@ -154,6 +154,10 @@ wasm32posix-cc -shared -fPIC plugin.c -o plugin.so --sysroot= # musl sysroot ``` +The musl objects in the SDK sysroot are compiled with the same Wasm exception +handling and SjLj lowering flags, so libc calls to `setjmp`/`longjmp` do not +leave unresolved host imports in linked programs. + ### Linker flags injected automatically ``` @@ -163,6 +167,7 @@ wasm32posix-cc -shared -fPIC plugin.c -o plugin.so -Wl,--shared-memory # Enable SharedArrayBuffer -Wl,--max-memory=1073741824 # 1GB max memory -Wl,--global-base=1114112 # Data segment start +-Wl,--no-stack-first # LLVM 22+: preserve stack-after-data layout -Wl,--allow-undefined # Host imports are resolved at load time -Wl,--export-table # Export function table (for dlopen) -Wl,--export=__stack_pointer # Required for fork/thread support diff --git a/docs/software-unit-tests.md b/docs/software-unit-tests.md new file mode 100644 index 0000000000..ca5d882759 --- /dev/null +++ b/docs/software-unit-tests.md @@ -0,0 +1,959 @@ +# Core Software Test Suites on Kandelo + +This project proves Kandelo by running real upstream/project test suites for +large guest software on both Node.js and browser hosts where possible. + +Status date: 2026-06-16. + +> **PHP evidence provenance:** the PHP counts in this document were imported +> from the pre-split source PR at commit `bc13ad8631a2`, based on the older +> platform stack. They combine chronological chunks with targeted reruns and +> are historical evidence, not results for PR #740, Batch 2, or the current +> repository head. Replace them only after an exact-tip Batch 2 run records its +> source, package, ABI, host, browser, command, and durable per-test outcomes. +> The current harness also records normal opcache SHM tests and partial +> `CAPTURE_STDIO` descriptor-inheritance tests as explicit unsupported platform +> boundaries; historical counts predate those truthfulness corrections. + +## Historical Status Snapshot + +| Project | What is wired today | Node host status | Browser host status | +|---------|---------------------|------------------|---------------------| +| MariaDB | `mysql-test/main/*.test` through `mysqltest` against `mariadbd` | Started full run, stopped after 710 results due Node heap OOM | Browser run reached the harness but failed VFS/init fetch and recorded 1149 failures | +| SQLite direct | Direct execution of each upstream Tcl `test/*.test` script once through `testfixture` | Completed 1159 scripts: 912 PASS, 36 FAIL, 15 XFAIL, 196 XPASS | Completed 1159 scripts: 876 PASS, 62 FAIL, 38 XFAIL, 173 XPASS, 10 TIME | +| SQLite official | Upstream `test/testrunner.tcl` permutations `full` and `all` | Completed corrected Node `full --jobs 2`: 1416/1416 official Tcl jobs finalized, 1416 passed, 0 failed, 1,703,255 SQLite internal cases, 0 case errors. The prior `busy2.test` failure was fixed by rebuilding the SQLite artifacts with `SQLITE_ENABLE_SETLK_TIMEOUT=2`, matching SQLite's own official lock-timeout test configuration; see `docs/sqlite-official-test-report.md` | Same inventory: 1416 `full` jobs and 10523 `all` jobs. Current browser iteration is past the earlier `writecrash.test` blocker and reached a timeout/stall checkpoint at 40/1416 jobs, 12,206 cases, 0 case errors, with `test/sort4.test` still running. Node isolated `sort4.test` passes 11/11 in 54s; browser isolated `sort4.test` was still at 0/1 after about 3 minutes before maintenance stop. See `docs/sqlite-official-test-report.md`. | +| PHP | PHPT runtime tests from the PHP source tree | Full discovered Node PHPT set completed on PR #2: 19,017/19,017 covered, 14,554 PASS, 0 FAIL, 0 TIMEOUT, 9 XFAIL, 1 XPASS, 3,987 SKIP, 466 UNSUPPORTED. | Browser harness is wired through the `php-test` Vite page and VFS image; current PR #2 browser coverage is still partial: 2,363/19,017 covered, 2,141 PASS, 2 FAIL, 1 XFAIL, 212 SKIP, 7 UNSUPPORTED. | +| SpiderMonkey smoke | Kandelo-authored shell coverage tests, not Mozilla's official suite | Completed 17/17 PASS | Completed 17/17 PASS | +| SpiderMonkey official | Mozilla `jstests.py` and `jit_test.py` harnesses using `js.wasm` through a Kandelo shell wrapper | Paused until the process-memory architecture bug is fixed, so Node/browser results stay comparable | Paused until the browser process-memory architecture bug is fixed | +| Node.js library | Upstream Node.js `test/parallel/test-*.js` and `test/sequential/test-*.js` through the SpiderMonkey-backed Node-compatible runtime | Completed 3925 tests: 336 PASS, 3264 FAIL, 325 TIME | Completed 3925 tests: 339 PASS, 3564 FAIL, 22 TIME | + +Logs from the 2026-05-28 full runs are under `test-runs/software-unit-tests/`. + +## 2026-06-16 PHP PHPT Node Source-PR Full Run + +php-src discovery finds **19,017** `.phpt` files from PHP **8.3.15**. The +source-PR Node run completed the full discovered set. The aggregate +uses chronological chunk results plus targeted reruns for tests whose earlier +results were invalidated by harness or external-service issues: + +| Host | Scope | Pass | XFAIL | XPASS | Fail | Timeout | Skip | Unsupported | Untested | Total | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Node | Chunked `--all`, historical source-PR head `bc13ad8631a2` | 14,554 | 9 | 1 | 0 | 0 | 3,987 | 466 | 0 | 19,017 | + +The bounded final segment used the restartable chunk harness. The chunk wrapper defaults to `--host node` and can also checkpoint browser runs with `--host browser`. Browser runs fingerprint the PHP source, binaries, extensions, rootfs, and VFS builder inputs and rebuild the image when they change; `--rebuild-vfs` remains an explicit force-rebuild option. The VFS builder honors an explicit `PHP_OPCACHE_SO` by writing it to `/usr/lib/php/extensions/opcache.so` after scanning `PHP_EXTENSION_DIR`, so a stale directory entry cannot override the requested OPcache side module: + +```bash +TEST_NON_ROOT_USER=nobody \ +TEST_FPM_RUN_AS_ROOT=1 \ +PHP_TEST_FAILURE_SNIPPET_BYTES=5000 \ +PHP_WASM=/tmp/kad-php-dynfork.wasm \ +PHP_FPM_WASM="$PWD/packages/registry/php/bin/php-fpm.wasm" \ +PHP_OPCACHE_SO=/tmp/kad-opcache-sidefork.so \ +PHP_EXTENSION_DIR="$PWD/packages/registry/php/bin" \ +PHP_TEST_CHUNK_SIZE=500 \ +PHP_TEST_JOBS=2 \ +PHP_TEST_TIMEOUT_MS=240000 \ +PHP_TEST_HOST_RESET_INTERVAL=1 \ +scripts/run-php-upstream-node-chunks.sh \ + --host node \ + --start-offset 8962 \ + --out-dir /tmp/kad-1-test-logs/php-node-bounded-chunks-from-8962-20260616075451 \ + --chunk-size 500 --jobs 2 --timeout 240000 --host-reset-interval 1 +``` + +Important reruns included: + +- `Zend/tests/generators/bug71441.phpt`, which now passes after increasing the + Node process-worker stack limit to 32 MiB. This is a general host stability + change for deep guest stacks, not PHP-specific behavior. Embedders with a + tighter memory budget can set `KANDELO_NODE_WORKER_STACK_SIZE_MB` to a + positive finite value; the browser harness instead launches Chromium with + an equivalent V8 stack limit because browser workers do not expose Node's + per-worker `resourceLimits` API. +- FPM non-root/virtual-ownership coverage, which now passes after allowing + host-backed mounts to expose stable virtual uid/gid metadata to the guest. +- Two online `httpbin.org` HTTP/1.1 PHPTs, which are now counted as upstream + skips under `SKIP_ONLINE_TESTS=1` after host `curl --http1.1` confirmed + `httpbin.org` currently returns HTTP/1.1 503. This is an external service + availability issue, not a Kandelo kernel failure. + +The only XPASS is +`sapi/fpm/tests/log-bwd-multiple-msgs-stdout-stderr.phpt`, an upstream +intermittent XFAIL that passed locally. + +Current skip/unsupported coverage gaps should be reduced through normal +runtime packaging and harness support: + +- Missing optional PHP extensions/dependencies: `intl`, `oci8`, `gd`, `curl`, + `ldap`, `ffi`, `gmp`, `imap`, `zip`, `pgsql`, and related extension suites. +- External services: MySQL/PDO MySQL connection tests require a running + compatible database service; the two `httpbin.org` online tests are skipped + while HTTP/1.1 requests to that service return 503. +- FPM/CGI/web PHPTs: the Node harness can now stage `php-fpm` when + `PHP_FPM_WASM` is set, and passes upstream's `TEST_FPM_RUN_AS_ROOT` control + env through to guest tests. This exposes real FPM coverage instead of + treating every FPM helper test as a CLI test: + + ```bash + TEST_FPM_RUN_AS_ROOT=1 \ + PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ + PHP_FPM_WASM="$PWD/packages/registry/php/bin/php-fpm.wasm" \ + PHP_OPCACHE_SO=/tmp/kad-opcache-sidefork.so \ + scripts/run-php-upstream-tests.sh --host node --json \ + sapi/fpm/tests/.phpt + ``` + +- web/CGI PHPT sections such as `EXPECTHEADERS`, `POST`, `POST_RAW`, `GET`, + `COOKIE`, `CGI`, `GZIP_POST`, `DEFLATE_POST`, and `REDIRECTTEST` still need + general harness support. +- Fibers require a real general `getcontext`/`makecontext`/`swapcontext` + implementation or another Wasm context-switching backend. +- phpdbg PHPTs require building and packaging the phpdbg SAPI. +- DNS record-query PHPTs require enabling a correct resolver backend in the + PHP build/runtime. + +## 2026-07-12 PHP ZIP Package Validation + +The PHP package now publishes `zip.so` as an opt-in shared extension backed by +the resolver-built libzip 1.11.4 package. The package currently targets wasm32 +and supports stored and DEFLATE entries; optional libzip crypto, BZIP2, LZMA, +and Zstandard backends are not enabled. This update does not revise the +historical full-run counts above. + +Focused validation on the exact Batch 2 ABI 19 serial tree covered the package +and both host paths: + +- A clean PHP build produced exactly the six manifest-declared outputs. Both + PHP executables report ABI 19, the outputs contain no producer paths, and all + 139 `zip.so` environment imports resolve through PHP's exported surface or + the host-provided shared memory. +- The focused Node/Vitest PHP package suite passed 19/19, including extension + registration and a DEFLATE close/reopen round trip. +- The real-Chromium PHP package test passed, loading `zip.so` from the + kernel-owned VFS and reopening a DEFLATE archive. +- Thirteen selected upstream `ext/zip` PHPTs passed on the Node host with no + skips or unsupported results. They cover registration, open/close, + add/extract/delete, compression, timestamps, progress and cancellation + callbacks, and supported-method reporting. + +A targeted browser-PHPT run built the fingerprinted PHP VFS image but did not +reach PHP: the general browser-demo page imports its complete shell-lazy +artifact catalog, and this ABI 19 scratch did not contain the unrelated +`less.wasm` artifact. Browser PHPT results are therefore not claimed here; the +dedicated real-Chromium ZIP test above is the browser runtime evidence for this +package change. + +## 2026-06-14 PHP PHPT Node Chunked Full Run + +php-src discovery still finds **19,017** `.phpt` files from PHP **8.3.15**. +The current no-skip-env Node run is using the restartable chunk harness added in +this PR update: + +```bash +PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ +PHP_OPCACHE_SO="$PWD/packages/registry/php/bin/opcache.so" \ + scripts/run-php-upstream-node-chunks.sh \ + --chunk-size 500 --jobs 4 --timeout 600000 \ + --host-reset-interval 25 \ + --out-dir /tmp/kad-1-test-logs/php-node-chunks-20260614225117 +``` + +This wrapper runs the same reusable PHPT harness in fresh Node.js processes per +chunk and writes resumable `chunk-.jsonl`, `.stderr`, `.exit`, `.done`, +`summary.json`, and `summary.md` artifacts. It avoids the previous monolithic +Node run shape, which reached **1,275 results** (**1,265 pass**, **10 skip**) +but grew to about **7.3 GiB RSS** and was killed before completion. + +Latest observed partial no-skip-env Node counts while the chunked run continues: + +| Host | Scope | Pass | XFAIL | Fail | Timeout | Skip | Unsupported | Untested | Total | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| Node | Chunked `--all`, offsets 0..current partial chunk | 1,519 | 0 | 1 | 0 | 18 | 0 | 17,479 | 19,017 | + +Current failing test observed in the partial run: + +- `Zend/tests/concat_003.phpt`: pure PHP performance threshold check. The test + expects concatenating a large set of strings to finish below the upstream + native-runtime threshold of 1.0 second; the Wasm PHP runtime currently reports + `bool(false)`. This is not yet tied to a POSIX/kernel semantic failure and + should not be papered over with PHP-specific kernel behavior. + +Current skips observed in the partial run are upstream `SKIPIF` gates for +64-bit-only Zend tests, Zend MM, and missing optional extensions (`curl`, +`intl`). These remain coverage gaps to reduce through general PHP runtime +packaging/host support, not through Kandelo kernel special cases. + +New general platform fix in this update: + +- AF_INET6 loopback stream listeners are registered through the same + cross-process host TCP bridge used for AF_INET loopback while preserving guest + IPv6 socket metadata. This lets a child process listen on `::1` and a sibling + process connect to `::1`, matching normal loopback behavior. Targeted Node + verification now passes: + +```bash +PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ +PHP_OPCACHE_SO="$PWD/packages/registry/php/bin/opcache.so" \ + scripts/run-php-upstream-tests.sh --host node --timeout 180000 --json \ + ext/openssl/tests/san_ipv6_peer_matching.phpt +# => PASS +``` + +Additional local validation for this update: + +- `cargo test -p kandelo inet6_loopback --target x86_64-unknown-linux-gnu` — 3 pass +- `bash packages/registry/kernel/build-kernel.sh` — rebuilt and installed `local-binaries/kernel.wasm` / `host/wasm/kandelo-kernel.wasm` + +## 2026-06-12 PHP PHPT Node Iteration + +php-src discovery currently finds **19,017** `.phpt` files from PHP **8.3.15**. + +Current no-skip-env Node full run command: + +```bash +PHP_WASM="$PWD/packages/registry/php/bin/php.wasm" \ +PHP_OPCACHE_SO="$PWD/packages/registry/php/bin/opcache.so" \ + scripts/run-php-upstream-tests.sh --host node --all --jobs 4 \ + --timeout 600000 --host-reset-interval 25 --json +``` + +The active log is recorded in `/tmp/kad-1-current-node-full-log`. + +Changes since the 2026-06-11 handoff: + +- The PHPT harness now has `--host-reset-interval` for Node. This reboots each + runner's Kandelo kernel after a bounded number of PHPTs, reclaiming + host-side WebAssembly memory the same way native `make test` gets OS process + reclamation between PHP invocations. `0` disables the reset. +- The PHP package now builds and ships `zend_test.so` as an opt-in shared + extension. Upstream php-src uses `zend_test` for engine coverage; making it a + normal loadable module removes those skips without loading test-only code by + default or special-casing the harness. +- PHP configure now passes `--disable-rpath`; wasm-ld does not support ELF + runtime library search path flags, and the Wasm PHP package links static + dependency archives and explicit side modules instead. +- Targeted Node checks after the rebuild: + - `Zend/tests/attributes/016_custom_attribute_validation.phpt`: PASS + - `Zend/tests/bug74093.phpt`: PASS, validating POSIX timer delivery for Zend + max-execution timers. + - `Zend/tests/new_oom.phpt`: PASS with `--timeout 600000`; it takes about + 177 seconds on this AO worker and can false-timeout under the older 180s + full-run timeout. + - The previous opcache/OpenSSL targeted failure set remains PASS. + - `Zend/tests/concat_003.phpt`: still FAIL. The measured timed section takes + about 4.3 seconds on the Wasm PHP runtime versus the upstream native + performance threshold of 1.0 second. This is not currently attributable to + a POSIX/kernel semantic failure. + + +## 2026-06-11 PHP PHPT Handoff Status + +php-src discovery currently finds **19,017** `.phpt` files from PHP **8.3.15**. + +Latest full/partial run evidence from this AO worker: + +| Host | Run | Pass | Fail | Time | Skip | Unsupported | XFAIL | Untested | Notes | +| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| Node | `SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 scripts/run-php-upstream-tests.sh --host node --all --jobs 6 --timeout 120000 --json` | 12,589 | 71 | 7 | 5,880 | 462 | 8 | 0 | Complete pre-isolation baseline in `/tmp/kad-1-test-logs/php-node-full-current-detached-20260611062754.jsonl`. Some failures were false negatives from parallel workers sharing one writable `/php-src`. | +| Node | Same command after per-worker source-root isolation | 8,532 | 29 | 6 | 4,714 | 272 | 6 | 5,458 | Run was killed by the worker with exit 137 at 13,559/19,017 in `/tmp/kad-1-test-logs/php-node-full-isolated-current-20260611103236.jsonl`; rerun is required. | +| Browser | Four concurrent shards, Nix Chromium, partial | 1,172 | 17 | 0 | 17 | 0 | 0 | 17,811 | Partial logs under `/tmp/kad-1-test-logs/php-browser-shards4-current/`; the run was stopped before completion to continue Node/kernel iteration. | + +Important interpretation of the Node skip count: the 5,880 skips are upstream +PHPT `SKIPIF` decisions, not harness failures. The largest groups are missing +optional PHP extensions/services in this build or environment: `soap` (552), +`intl` (524), `opcache` (500 in the old run), MySQL connection refused (391), +`oci8` (330), `gd` (292), `zend_test` (162), PDO MySQL connection refused +(145), `curl` (142), 64-bit-only tests (140), Windows-only tests (124), and +FPM/root guards (123). + +The `opcache` skips in the complete Node baseline were a harness configuration +gap. Kandelo ships opcache as the separate Zend extension side module +`opcache.so`; it is not statically loaded into `php.wasm`. The harness now loads +known available shared extensions requested by `--EXTENSIONS--` with the proper +`zend_extension=` directive, recognizes PHP's loaded extension name +`Zend OPcache` as satisfying `opcache`, and writes `opcache.so` into the browser +PHPT VFS image. Targeted verification after this fix: + +```bash +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host node --timeout 60000 --json \ + ext/opcache/tests/bool_cp_in_pass1.phpt +# => PASS +``` + +Kernel/host fixes present in that historical source-PR handoff: + +- POSIX wait status encoding now distinguishes normal exits from signal deaths. + Normal `_exit(status)` is masked to 8 bits and reported to `waitpid(2)` as + `(status & 0xff) << 8`; signal termination records a separate signal number + and reports the low 7-bit signal status. Host-side process scans still expose + shell-style `128 + signal` where that API expects it. +- Hosts can mark captured stdio descriptors as pipes. The descriptor keeps its + host stdio handle for I/O, but `fstat(2)` reports FIFO metadata and + `isatty(3)` observes non-terminal behavior. This is needed for PHPT + `--CAPTURE_STDIO--` cases and is a general POSIX metadata correction. +- Centralized `fork(2)` retries host PID allocation when the kernel still owns a + zombie/limbo PID. The kernel remains the source of truth for PID occupancy; + `fork(2)` callers should not observe an internal `EEXIST` collision. +- Thread exit now clears `CLONE_CHILD_CLEARTID` storage and wakes the futex wait + word, matching Linux pthread join expectations. +- Centralized host/kernel calls that pass guest pointers now route through the + host pointer-width helper instead of hard-coded `BigInt` arguments. + +PHP harness fixes present in that historical source-PR handoff: + +- Node `--jobs N` uses one copied php-src tree per worker, avoiding cross-test + contamination from generated `.php`/`.clean.php` files and tests that mutate + source-adjacent fixtures. Targeted rerun of failures caused by shared source + state passed 7/7 after this change. +- Node runner reuses a host for throughput but resets it after section timeouts; + timeout handling no longer leaves the worker stuck for later PHPTs. +- Combined stdout/stderr ordering is captured from host callbacks so PHPT + expectations that intentionally interleave warnings and output compare + correctly. +- PHPT placeholder handling now includes `{TMP}`, `{MAIL:...}`, and `{ENV:...}` + in addition to `{PWD}`. +- `TEST_PHP_EXTRA_ARGS` is kept empty, matching upstream use as extra switches + rather than an executable path. +- Browser PHPT runs can use `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH`; on this AO + runner a Nix Chromium binary was used because Playwright's downloaded browser + lacked system shared libraries. + +Local checks run before this handoff commit: + +- `git diff --check` +- `npm --prefix host run typecheck` +- `npm --prefix host test -- --run test/multi-worker.test.ts test/select-timeout-retry.test.ts` — 12 pass +- `cargo test -p kandelo --target x86_64-unknown-linux-gnu poll_waitable_child --lib` — 5 pass +- Targeted Node PHPT opcache side-module check above — 1 pass + +Recommended resume commands: + +```bash +# Fast targeted verification for the opcache side-module harness fix. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host node --timeout 60000 --json \ + ext/opcache/tests/bool_cp_in_pass1.phpt + +# Full Node rerun; use --jobs on a machine with enough memory. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host node --all --jobs 6 \ + --timeout 120000 --json + +# Browser smoke after rebuilding the VFS with opcache.so included. +CHROMIUM=$(nix shell nixpkgs#chromium --command sh -lc 'command -v chromium') +PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM" \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host browser --rebuild-vfs \ + --limit 3 --timeout 90000 --json + +# Browser full run should be sharded to reduce memory pressure. +CHROMIUM=$(nix shell nixpkgs#chromium --command sh -lc 'command -v chromium') +PHP_TEST_VITE_PORT=5231 PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH="$CHROMIUM" \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 SKIP_PERF_SENSITIVE=1 \ + scripts/run-php-upstream-tests.sh --host browser --all --shard 1/4 \ + --timeout 90000 --json +``` + +## 2026-06-05 PHP PHPT Harness Notes + +The PHP PHPT harness is `scripts/run-php-upstream-tests.sh`. It runs the +upstream `php-src` `.phpt` inventory against Kandelo without calling native +`run-tests.php` directly: each `--EXTENSIONS--`, `--SKIPIF--`, `--FILE--`, +and `--CLEAN--` section is executed as a PHP process inside Kandelo, then the +harness applies the PHPT expectation match. + +Current defaults use the PHP package source metadata, which now matches the +PHP binary built by `packages/registry/php/build-php.sh` (PHP 8.3.15). The +node host mounts the source tree at `/php-src`, mounts the PHP binary +directory at `/kandelo-bin`, and runs tests from `/php-src` to match upstream +`run-tests.php` working-directory semantics. The browser host uses the +`php-test` Vite page and `apps/browser-demos/public/php-test.vfs.zst`. The +harness fingerprints the inputs and rebuilds a stale image automatically; +`--rebuild-vfs` forces a rebuild when diagnosing the builder itself. + +Recommended commands while iterating: + +```bash +# Historical source-PR command and result for an ext/standard tranche: +# 537 total, 466 pass, 70 skip, 1 unsupported. Rerun before citing it for the +# current tree. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node \ + ext/standard/tests/time ext/standard/tests/versioning \ + ext/standard/tests/directory ext/standard/tests/crypt \ + ext/standard/tests/ini_info ext/standard/tests/hrtime \ + ext/standard/tests/password ext/standard/tests/misc \ + ext/standard/tests/assert ext/standard/tests/url \ + ext/standard/tests/filters ext/standard/tests/class_object \ + ext/standard/tests/image ext/standard/tests/math \ + ext/standard/tests/serialize \ + --timeout 60000 --json + +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser \ + ext/standard/tests/time ext/standard/tests/versioning \ + ext/standard/tests/directory ext/standard/tests/crypt \ + ext/standard/tests/ini_info ext/standard/tests/hrtime \ + ext/standard/tests/password ext/standard/tests/misc \ + ext/standard/tests/assert ext/standard/tests/url \ + ext/standard/tests/filters ext/standard/tests/class_object \ + ext/standard/tests/image ext/standard/tests/math \ + ext/standard/tests/serialize \ + --timeout 60000 --json + +# Historical source-PR result for ext/standard strings: +# 716 total, 663 pass, 53 skip. Rerun before citing it for the current tree. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node ext/standard/tests/strings --timeout 60000 --json + +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser ext/standard/tests/strings --timeout 60000 --json + +# Historical source-PR result for ext/standard array: +# 817 total, 802 pass, 15 skip. Rerun before citing it for the current tree. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node ext/standard/tests/array --timeout 60000 --json + +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser ext/standard/tests/array --timeout 60000 --json + +# Node host. Shard full runs; SKIP_* vars are upstream PHPT control env. +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host node --all --shard 1/16 --timeout 180000 --json + +# Browser host. Requires Playwright's shared library deps on this AO runner. +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +SKIP_SLOW_TESTS=1 SKIP_ONLINE_TESTS=1 scripts/run-php-upstream-tests.sh \ + --host browser --all --shard 1/16 --timeout 180000 --json + +# Rebuild the browser PHPT VFS image. The image includes /bin/sh and +# standard utilities so PHP shell-backed APIs such as system()/exec() work. +LD_LIBRARY_PATH=/tmp/pw-deps/root/usr/lib/x86_64-linux-gnu \ +scripts/run-php-upstream-tests.sh \ + --host browser --rebuild-vfs --limit 3 --timeout 90000 --json +``` + +The browser VFS builder layers PHP and the PHPT source tree onto the canonical +`rootfs.vfs`, so shell and utility coverage comes from the same packaged rootfs +used by other Kandelo hosts. Set `ROOTFS_VFS` or `PHP_WASM` only when testing an +explicit alternate artifact. The browser page rewrites the rootfs's relative +lazy executable URLs to Vite-managed assets before the legacy shared-filesystem +runner registers them with the kernel worker. + +Historical kernel/POSIX fixes found by the pre-split PHPT effort (not all are +part of PR #740 or its current landing branch): + +- Pathname resolution must be component-wise. Kandelo no longer collapses + `missing/..` lexically before the backend can report `ENOENT`. +- A trailing slash is significant: it requires the preceding component to + resolve as a directory, while `mkdir("newdir/")` still uses the parent of + `newdir`. +- `..` at a VFS mount root resolves to the parent mount instead of being + treated as an escape from the host-backed mount. +- Empty pathnames now fail with `ENOENT` instead of resolving to the current + directory. +- `getcwd(2)` validates that the current working directory still exists and + returns `ENOENT` after it is removed. Cwd and directory OFDs remain + pathname-backed, not inode-backed: rename/unlink followed by recreation can + make that stored spelling identify a different directory. +- `chdir(2)` stores a canonical current working directory after successful + component-wise resolution, so later `getcwd(2)` does not expose literal `.` + or `..` path components. +- Host-backed absolute symlinks that point inside their guest mount are + followed for `stat`/`open` while `readlink` still returns the original guest + target text. +- BSD `flock(2)` locks are open-file-description locks. `LOCK_SH` is allowed + on write-only descriptors, separate opens in the same process conflict, and + `LOCK_NB` returns `EAGAIN` instead of being retried as a blocking syscall. +- PHPT section semantics now match upstream more closely: test `--INI--` is + applied to `--FILE--` only, stable generated names are used for `--FILE--` + and `--CLEAN--`, `--INI--` assignment whitespace is normalized, `{PWD}` in + `--INI--`/`--ENV--` expands to the guest test directory, PHP-style trim + removes edge NUL bytes for EXPECT matching, PHPT source/output bytes are + preserved instead of UTF-8 decoded, EXPECTF `%r...%r` regex spans and + percent placeholders follow upstream substitution ordering, flaky PHPTs retry + once, matching tests after SKIPIF `warn` output are classified `WARN`, and a + warned mismatch retains failure status plus warning context so the aggregate + command cannot hide the mismatch. Selected upstream control env vars such as + `SKIP_SLOW_TESTS` pass through to guest PHP. +- Kandelo deliberately reports a passing PHP `--XFAIL--` as `XPASS` and makes + it fail the harness command. Upstream PHP 8.3 reports that case as `WARN`; + Kandelo's stricter policy keeps stale expected-failure annotations visible. +- Stream/socket behavior now covers the standard cases exercised by PHP's + stream suite: abstract AF_UNIX addresses are not filesystem-backed, UDP + `INADDR_ANY` destinations route to loopback, AF_INET6 loopback sockaddrs are + round-tripped, accepted sockets preserve Kandelo's nonblocking status + contract, and malformed numeric IPv4 names fail resolution instead of being + treated as browser synthetic DNS names. +- Additional network PHPT coverage now passes for AF_UNIX datagram loopback, + AF_INET6 UDP loopback, and browser-side rejection of syntactically invalid + DNS names instead of assigning synthetic addresses to them. + +## 2026-06-02 SQLite Allocator Status + +After rebasing onto `origin/main` at `2e6293a50ccf996b1a434aa701057b862a46c587`, +the next official SQLite Node-host run reached 77/1416 jobs and 22419 SQLite +cases with 0 case errors before repeated wasm `unreachable` traps appeared in +path-heavy `stat`/`lstat` calls. + +The trap mapped to the wasm kernel allocator path, not to SQLite itself: +`crates/kernel/src/lib.rs` used a global bump allocator whose `dealloc` was a +no-op. Temporary Rust allocations such as `Vec` allocations in path +normalization were leaked for the lifetime of the centralized kernel. Long +official SQLite runs therefore exhausted the kernel heap. + +The wasm kernel now uses a lock-protected `dlmalloc::Dlmalloc` global allocator +with real `dealloc`, `alloc_zeroed`, and `realloc`. Validation so far: + +- Wasm kernel release build passed. +- Host build passed. +- Host typecheck passed. +- Focused official SQLite Node run of `savepoint6.test`, + `fts5origintext5.test`, `fts5ah.test`, and `sort4.test` completed 3/4 jobs + before a 10 minute outer timeout: 10189 SQLite cases, 0 case errors. +- The incomplete focused job was `test/sort4.test`, which is marked + `TESTRUNNER: superslow`; it was still running, not failed. + +This fixes the immediate kernel heap leak/allocator exhaustion class. It does +not yet provide a complete SQLite `full` pass/fail matrix. + +## 2026-06-03 SQLite Retry Status + +Current focus remains official SQLite `full`, not SpiderMonkey. The Node-host +run was restarted after three root-cause fixes: + +- Stale file-backed `MAP_SHARED` tracking after page-rounded `munmap` could + corrupt anonymous mappings reused at the same address. This reproduced in + `test/wal.test`; direct rerun now passes 581/581 cases. +- Rapid pthread create/join loops could exhaust the 16 reserved thread slots + because slots were freed only after a later JS worker message. Slots are now + reclaimed when the kernel confirms thread `SYS_EXIT`. Direct `sort4.test` + now passes 11/11 without slot-exhaustion output, and the new + `thread-slot-reuse` regression creates/joins 64 threads successfully. +- The next full `--jobs 4` run reached 74/1416 jobs before the official + runner's own `testrunner.db` became genuinely malformed. The corrupt DB had + zero-filled low pages, including the jobs table root page and the overflow + page for the `CREATE TABLE jobs` schema record. The root cause was that + centralized direct handlers for large `write`/`pwrite` and `writev`/`pwritev` + bypassed the normal post-syscall shared-backing update path. A stale + file-backed mapping cache could then flush or expose zero pages after the + real file had been written. Those direct handlers now update and refresh + shared backings before unblocking the guest. Regression: + `examples/mmap_shared_large_pwrite.c`. + +Focused validation now passing on Node: + +- `host/test/mmap-shared.test.ts`: 3 passed, 1 skipped. +- `host/test/pthread.test.ts`: 3 passed. +- Direct official SQLite jobs: `wal.test` 581/581, `writecrash.test` 995/995, + `rtree4.test` 112471/112471, `sort4.test` 11/11, and + `fts5optimize2.test` 4/4. + +Live official run: +`test-runs/sqlite-full-node-j4-after-large-write-sync-20260603-151517`. +Latest recorded stdout progress is at least 364/1416 jobs (25.71%), past the +previous 74-job malformed-DB blocker, with no visible SQLite case errors or +Kandelo runtime failures. Live DB reads are intentionally avoided while the +guest runner owns the WAL-mode control database; case counts are taken from +`testrunner.log` snapshots until the run finishes. + +## 2026-06-02 SQLite Official Status + +Detailed report: `docs/sqlite-official-test-report.md`. + +The answer to "do we know exactly what parts of the full SQLite suite pass and +fail on Kandelo?" is currently no. We know the official inventory and we have +targeted official job results, but neither the Node nor browser host can yet +finish `full --jobs 1` and produce a trustworthy complete runner database. + +The most important blocker is file-backed `MAP_SHARED` coherency. Kandelo +currently populates file-backed mappings by copying file bytes into each guest +process memory and writes mapped bytes back on `msync`/`munmap`. SQLite WAL +uses a file-backed `test.db-shm` mapping as live shared memory and does not +depend on `msync` for WAL-index coherence. That makes the observed +`busy2.test`, `wal3.test`, and `walsetlk.test` failures kernel/filesystem +correctness failures to fix before treating full SQLite numbers as meaningful. + +Other known blockers are the browser `SharedFS` 64-FD cap reducing +`manydb.test`, a SQLite testfixture build mismatch around +`SQLITE_ENABLE_UPDATE_DELETE_LIMIT`, and a browser artifact bug where the +timeboxed full run exported a valid 1024-byte SQLite DB with no `jobs` table. + +## 2026-06-01 SQLite Rebase Status + +The branch was rebased onto `origin/main` at +`95e31d2588e8fa7653796e0245c023f26fc59556` ("Reduce initial process memory +allocation"). The old branch was preserved as +`backup/prove-by-guest-software-tests-pre-main-rebase-20260601`. + +The rebase plus current work fixed the immediate SQLite kernel trap found after +the main memory-layout changes: + +- The first post-rebase full SQLite run failed in the kernel on `munmap`. + The root issue was `MemoryManager::munmap` rebuilding the entire mapping + table into a fresh `Vec` on every unmap; in the wasm kernel, allocation + failure/panic becomes `unreachable`. +- `MemoryManager::munmap` now updates mappings in place, preserves existing + mapping-table storage for non-splitting unmaps, and propagates `ENOMEM` only + if a middle split cannot reserve one extra slot. +- Validation: native kernel unit tests passed 866/866; focused host lifecycle + regressions passed 14/14; the targeted SQLite repro set + (`capi2.test`, `avtrans.test`, `temptable2.test`, `backup_malloc.test`) + passed 4/4 jobs and 2572/2572 cases with 0 errors in + `test-runs/main-rebase-full/20260601-155813-sqlite-targeted/`. + +The next full Node-host official SQLite run was stopped, not completed: + +- Run root: + `test-runs/main-rebase-full/20260601-160004-sqlite-full-node/`. +- At stop time the database had 1394 total jobs, 45 done, 91 failed, + 4 running, 1254 ready, 8183 reported cases, and 4554 case errors. +- The run log had no `handleSyscall kernel threw` or wasm out-of-bounds lines. + It did include one guest `testfixture.wasm` `unreachable` from pid 499; the + parent testrunner continued afterward, so this is not currently classified as + the same kernel-fatal class as the earlier `munmap` trap. +- The dominant remaining failure pattern is SQLite-level `database is locked` + output. A serial subset rerun with `--jobs 1` reduced five previously noisy + failures to 5 jobs, 267 cases, and 3 case errors: + `test-runs/main-rebase-full/20260601-160321-sqlite-failed-subset-j1/`. + In that subset, `tkt3731.test`, `func4.test`, and `vacuum5.test` passed; + `writecrash.test` still failed with `database is locked`, and `upfrom4.test` + still failed two SQL-result cases. + +Official SQLite suite inventory after the `getdents64` fix: + +- The large SQLite test suite is defined by upstream + `packages/registry/sqlite/sqlite-full-src/test/testrunner.tcl`, with Tcl + file sets from `test/permutations.test` and the `all` config list from + `test/testrunner_data.tcl`. +- `full` means the full Tcl file set. Current explain plans queue 1416 Tcl + jobs on both Node and browser hosts: + `test-runs/main-rebase-full/20260601-212612-sqlite-full-explain-node-getdents-fix/` + and + `test-runs/main-rebase-full/20260601-212612-sqlite-full-explain-browser-getdents-fix/`. +- `all` means `full` plus SQLite's official config permutations. Current + explain plans queue 10523 Tcl jobs on both Node and browser hosts: + `test-runs/main-rebase-full/20260601-212633-sqlite-all-explain-node-getdents-fix/` + and + `test-runs/main-rebase-full/20260601-212657-sqlite-all-explain-browser-getdents-fix/`. + The largest config groups are `full` 1416, `memsubsys1` 1329, + `memsubsys2` 1330, `no_mutex_try` 1331, `inmemory_journal` 1256, + `journaltest` 1164, `prepare` 1221, and `mmap` 1224. +- SQLite's "around 300,000 tests" figure refers to the internal case counts + each Tcl job reports as `N errors out of M tests`. Those counts are not known + from `--explain`; they are aggregated into `testrunner.db` as jobs execute. + Earlier stopped official `full` runs had already reported 586267, 836413, + and 839152 cases before completion, so the official path is the path that + reaches the hundreds-of-thousands case count. +- `scripts/run-software-unit-tests.sh` now runs SQLite through the official + testrunner by default. Set `SQLITE_OFFICIAL_PERMUTATION=all` to run the + wider permutation set. + +The earlier 1394/1393 `full` explain counts were wrong. The VFS image +contained the missing files, but the kernel consumed a host directory entry +before checking whether the guest `getdents64` buffer had room for it. If the +entry did not fit, the syscall returned without preserving that entry, so the +next `getdents64` call skipped it. `OpenFileDesc` now carries one pending +directory entry across calls, `lseek(SEEK_SET)` clears that pending entry, and +`sys_getdents64` advances the directory offset only after successfully writing +an entry to guest memory. The regressions +`test_getdents64_keeps_entry_that_does_not_fit` and +`test_getdents64_resumes_synthetic_entries_after_full_buffer` cover these +boundary cases. + +The next official Node `full --jobs 1` run was intentionally stopped before +completion: + +- Run root: + `test-runs/main-rebase-full/20260601-220000-sqlite-full-node-j1-getdents-synth-fix/`. +- At stop time it had 1416 jobs queued, 17 done, 1 failed, 1 running, + 1397 ready, 102908 reported SQLite cases, and 10 case errors. +- The failed job was `test/busy2.test`. Its first two failures showed + `PRAGMA journal_mode = wal` returning `delete`. That was an invalid Kandelo + artifact, not an upstream-suite issue: `packages/registry/sqlite/build-sqlite.sh` + and `packages/registry/sqlite/build-testfixture.sh` both used + `-DSQLITE_OMIT_WAL`. +- The no-WAL flag has been removed from both builds. `sqlite3.wasm`, + `testfixture.wasm`, and `apps/browser-demos/public/sqlite-test.vfs.zst` + were rebuilt with WAL enabled. +- Targeted validation through the official runner: + `test-runs/sqlite-official-node-full/20260601-213948/` ran + `busy2.test` and reduced the failure to 4 errors out of 29 cases. WAL mode + now works; the remaining failures are checkpoint/accounting differences: + expected `wal_checkpoint` results such as `{0 4 3}` are observed as + `{0 4 0}` or `{0 3 3}`. + +The remaining `busy2.test` failures expose a real platform bug, not a harness +problem. SQLite WAL uses byte-range locks plus a file-backed `MAP_SHARED` +mapping of `test.db-shm` as live shared memory. Kandelo currently populates a +file-backed mapping by copying file bytes into each process memory and writes +MAP_SHARED data back only on `msync` or `munmap`. That is not coherent shared +memory between separately allocated guest process memories. SQLite does not +use `msync` for its WAL index, so separate processes can observe stale +wal-index state even though fcntl byte locks are visible. The root fix is to +implement sound file-backed `MAP_SHARED` coherency across processes, then rerun +`busy2.test` and restart the full official SQLite run. + +SpiderMonkey official tests remain paused until the SQLite/kernel reliability +work is stable. The previous SpiderMonkey official Node path did not include +browser-host official execution, so it is not counted as proof for the +platform. + +## 2026-05-29 SQLite/SpiderMonkey Status + +SpiderMonkey official tests are intentionally paused. The browser host still +allocates a full 1 GiB shared WebAssembly memory per guest process because the +syscall channel is placed near max memory. That must be fixed in the memory +layout, not worked around in the harness, before official SpiderMonkey browser +numbers are meaningful. + +SQLite official `full` on the Node host is the active focus: + +- `test-runs/sqlite-official-node-full-thread-ceiling/20260529-142042/` + was stopped after the old Node crash path wedged parent `waitpid`. The DB + had 1394 jobs, 560 done, 21 failed, 4 running, 809 ready, 586267 reported + test cases, and 451 case errors. +- `test-runs/sqlite-official-node-full-crash-reap-fix/20260529-155403/` + progressed further, then was stopped on a kernel wasm `memory access out of + bounds` while `waitpid` reaped a child through `kernel_remove_process`. After + the process was stopped, the DB passed `pragma integrity_check` and reported + 1394 jobs, 873 done, 41 failed, 4 running, 476 ready, 836413 reported test + cases, and 617 case errors. +- The kernel allocator now records allocation metadata and coalesces free-list + overlaps defensively instead of deriving the free interval from the caller's + layout. The Node worker crash path now ignores duplicate error notifications + after the process has already been removed from the host process map. +- Validation after those fixes: kernel memory-manager unit tests passed 20/20, + `host/test/wasm-trap.test.ts` passed 3/3, and a targeted official SQLite run + of `fallocate.test` and `select7.test` failed cleanly with 2 reported case + errors instead of hanging. +- `test-runs/sqlite-official-node-full-allocator-fix/20260529-173059/` + reached 1394 jobs total, 875 done, 39 failed, 4 running, 476 ready, + 839152 reported test cases, and 612 case errors before it was stopped. The + first kernel failure was again `RuntimeError: memory access out of bounds` + during `kernel_remove_process` from `consumeExitedChild()` while handling + `waitpid`; active jobs were `pagerfault2.test`, `analyzeE.test`, + `fts3fault.test`, and `backup_ioerr.test`. +- The new OOB mapped to a `memory.copy` inside Rust's BTreeMap removal of a + large `Process` value. The most plausible root cause found so far was a + wasm-only allocator bug: allocations carved from a free block could leave the + suffix `FreeNode` at an unaligned `user + requested` address. That alignment + bug is now patched, and deallocation rejects unaligned allocation metadata. + This is under validation, not yet proven by a complete SQLite `full` run. +- Validation after the alignment patch: full native kernel tests passed + 850/850, `host/test/wasm-trap.test.ts` passed 3/3, and a targeted official + SQLite run for `pagerfault2.test`, `analyzeE.test`, `fts3fault.test`, and + `backup_ioerr.test` is running under + `test-runs/sqlite-official-node-reap-oob-regression/20260529-193249/`. + +SQLite's upstream testrunner repeatedly prints +`WARNING: Multi-threaded tests skipped: Linked against a non-threadsafe Tcl build`. +Those skipped Tcl-threaded cases are a caveat on all current SQLite official +numbers. + +## Important Corrections + +The previous status overstated two areas: + +- The `scripts/run-spidermonkey-unit-tests.sh` runner is not the official + Mozilla SpiderMonkey suite. It is a Kandelo smoke suite that exercises shell + builtins, Intl, workers, Atomics, file APIs, GC pressure, promises, and error + handling. Mozilla's official shell suites are `js/src/tests/jstests.py` and + `js/src/jit-test/jit_test.py`. +- The SQLite results counted Tcl test scripts, not individual SQLite test + cases. SQLite's own documentation distinguishes `veryquick`, `full`, `all`, + and `release`: `full` is all Tcl scripts, `all` is `full` plus permutations, + and `release` runs many build configurations plus fuzz/thread/mptest-style + work. The completed Kandelo runs executed the 1159 Tcl scripts once; they did + not run SQLite's official `all` or `release` permutations. + +`scripts/run-sqlite-upstream-tests.sh` and the browser SQLite runner now parse +and aggregate the internal `N errors out of M tests` counts for future runs. +The 2026-05-28 logs do not contain the per-script stdout needed to reconstruct +the exact SQLite case count after the fact. + +## Entry Points + +```bash +# Default pragmatic suite set on both hosts. SpiderMonkey here is the smoke +# suite because official browser-host SpiderMonkey is not wired yet. +scripts/run-software-unit-tests.sh + +# Run one host. +scripts/run-software-unit-tests.sh --host node +scripts/run-software-unit-tests.sh --host browser + +# Run selected suites. `mysql` is accepted as an alias for MariaDB. +scripts/run-software-unit-tests.sh --host browser sqlite php nodejs +scripts/run-software-unit-tests.sh --host node mariadb spidermonkey-official +``` + +| Suite | Node.js host | Browser host | +|-------|--------------|--------------| +| MariaDB / mysql-test | `scripts/run-mariadb-tests.sh --all` | `scripts/run-browser-mariadb-tests.sh --all` | +| SQLite direct Tcl scripts | `scripts/run-sqlite-upstream-tests.sh --all` | `scripts/run-browser-sqlite-upstream-tests.sh --all` | +| SQLite official testrunner | `scripts/run-sqlite-official-tests.sh --host node --permutation full` | `scripts/run-sqlite-official-tests.sh --host browser --permutation full` | +| PHP PHPT runtime tests | `scripts/run-php-upstream-tests.sh --host node --all` | `scripts/run-php-upstream-tests.sh --host browser --all` | + +PHP PHPT harness notes: + +```bash +# Full php-src PHPT run (all discovered .phpt files). +scripts/run-php-upstream-tests.sh --host node --all +scripts/run-php-upstream-tests.sh --host browser --all + +# Smoke/debug a prefix or selector and emit machine-readable results. +scripts/run-php-upstream-tests.sh --host node --limit 25 --json +scripts/run-php-upstream-tests.sh --host browser Zend/tests/001.phpt --json + +# Split a full sorted discovery set for lower-memory CI or AO shards. +scripts/run-php-upstream-tests.sh --host node --all --shard 1/16 --json +scripts/run-php-upstream-tests.sh --host browser --all --offset 500 --limit 100 --json + +# Write docs/php-upstream-test-report.md for the selected host/run. +scripts/run-php-upstream-tests.sh --host node --all --report +``` + +The PHPT harness writes the generated `--FILE--` section as the upstream +`.php` beside each `.phpt` file, then restores any pre-existing file. +This matches php-src's `run-tests.php` behavior for tests that assert `__FILE__` +or exception source locations. Browser runs use the same generated path inside +the `/php-src` VFS image and start a temporary Vite server; set +`PHP_TEST_VITE_PORT` if port `5201` is occupied. Browser PHPT runs pass Chromium +Wasm stack-switching flags by default so stack-heavy guest workloads get a +larger secondary Wasm stack in dedicated workers. Add extra browser flags with +`PHP_TEST_CHROMIUM_ARGS` or `KANDELO_CHROMIUM_ARGS`; set +`PHP_TEST_DISABLE_BROWSER_WASM_STACK_FLAGS=1` only when debugging those default +stack settings. + +The runner also mirrors `run-tests.php` comparison and working-directory +semantics: CRLF is normalized and both actual and expected output are trimmed +before comparison, `EXPECTF` placeholders include php-src's `%r...%r` regex and +`%0` NUL forms, and each PHP process runs with `TEST_PHP_SRCDIR` as its current +directory so source-root-relative paths such as `./ext/standard/tests/file` +behave like upstream. + +| SpiderMonkey smoke | `scripts/run-spidermonkey-unit-tests.sh --host node` | `scripts/run-spidermonkey-unit-tests.sh --host browser` | +| SpiderMonkey official | `scripts/run-spidermonkey-official-tests.sh --host node --suite both` | Not implemented | +| Node.js library tests | `scripts/run-nodejs-library-tests.sh --host node --all` | `scripts/run-nodejs-library-tests.sh --host browser --all` | + +## Official SpiderMonkey + +The official Node-host path uses Mozilla's Python harnesses with an executable +shim at `scripts/kandelo-js-shell-wrapper.sh`. The shim is passed to the +official harness as the `js` shell, but it runs `packages/registry/spidermonkey/bin/js.wasm` +inside Kandelo via `examples/run-example.ts`. + +`jstests.py` is run with shell WPT disabled by default +(`SPIDERMONKEY_OFFICIAL_WPT=disabled`) because the local Firefox source tree is +missing some Python import path setup needed by the WPT manifest updater. Set +`SPIDERMONKEY_OFFICIAL_WPT=enabled` after that dependency path is fixed. + +```bash +# One small official smoke from each Mozilla harness. +scripts/run-spidermonkey-official-tests.sh --suite both --smoke + +# Full official JS shell tests. +scripts/run-spidermonkey-official-tests.sh --suite jstests +scripts/run-spidermonkey-official-tests.sh --suite jit-tests +scripts/run-spidermonkey-official-tests.sh --suite both + +# Pass jstests path selectors after --. +scripts/run-spidermonkey-official-tests.sh --suite jstests -- non262/Array/array-001.js +``` + +Browser-host official SpiderMonkey remains open work. The browser would need a +persistent Playwright/Vite bridge or a browser-native implementation of the +Mozilla harness command scheduling, plus a VFS image containing the official +`js/src/tests` and `js/src/jit-test` trees. + +## SQLite Scope + +There are now two SQLite paths: + +- Direct script runner: runs each `test/*.test` file once through `testfixture`. + This is the runner used for the completed Node and browser results above. +- Official testrunner: invokes SQLite's upstream `test/testrunner.tcl` for + `veryquick`, `full`, or `all` on the Node or browser host. A `full main.test` + smoke run completed with `0 errors out of 95 tests`. + +```bash +# Direct runner, one pass over test/*.test. +scripts/run-sqlite-upstream-tests.sh --all +scripts/run-browser-sqlite-upstream-tests.sh --all + +# Official upstream testrunner permutations. +scripts/run-sqlite-official-tests.sh --host node --permutation veryquick +scripts/run-sqlite-official-tests.sh --host node --permutation full +scripts/run-sqlite-official-tests.sh --host node --permutation all +scripts/run-sqlite-official-tests.sh --host browser --permutation full + +# Explain planned official work without running it. +scripts/run-sqlite-official-tests.sh --host node --permutation all --explain +``` + +SQLite `release`, `mdevtest`, and `sdevtest` are not wired as Kandelo guest +runs yet because they require rebuilding multiple host configurations and +running additional fuzz/thread/mptest binaries. + +## Prerequisites + +Build or fetch `kernel.wasm` before running any browser or Node suite: + +```bash +bash build.sh +# or +scripts/fetch-binaries.sh +``` + +MariaDB needs `mariadbd`, `mysqltest.wasm`, and the `mysql-test/` tree: + +```bash +bash packages/registry/mariadb/build-mariadb.sh +``` + +SQLite needs Tcl, SQLite, and the testfixture binary: + +```bash +bash packages/registry/tcl/build-tcl.sh +bash packages/registry/sqlite/build-sqlite.sh +bash packages/registry/sqlite/build-testfixture.sh +``` + +PHP needs the CLI wasm binary. The PHPT source tree is taken from +`PHP_SOURCE_DIR`, a local `packages/registry/php/php-src`, or the package +source tarball: + +```bash +bash packages/registry/php/build-php.sh +``` + +SpiderMonkey needs the standalone JS shell wasm binary: + +```bash +bash packages/registry/spidermonkey/build-spidermonkey.sh +``` + +Node.js library tests use the SpiderMonkey-backed `node.wasm` package and the +upstream Node.js source tree. By default the runner downloads the source bundle +matching the host `node` version and verifies it with Node.js `SHASUMS256.txt`; +override with `NODEJS_TEST_VERSION` or `NODEJS_SOURCE_DIR`: + +```bash +bash packages/registry/spidermonkey-node/build-spidermonkey-node.sh +scripts/run-nodejs-library-tests.sh --host node --list +``` + +Browser runs build suite-specific VFS images under +`apps/browser-demos/public/` when missing: + +```bash +bash images/vfs/scripts/build-sqlite-test-vfs-image.sh +bash images/vfs/scripts/build-php-test-vfs-image.sh +bash images/vfs/scripts/build-spidermonkey-test-vfs-image.sh +bash images/vfs/scripts/build-nodejs-test-vfs-image.sh +``` diff --git a/docs/wasm-limitations.md b/docs/wasm-limitations.md index 1a87903fa5..a448ac8194 100644 --- a/docs/wasm-limitations.md +++ b/docs/wasm-limitations.md @@ -52,7 +52,32 @@ Kernel-side `sys_munmap` correctly updates the MemoryManager range tracking (so Until one of those ships, this remains a fundamental wasm limitation. The kernel's optional syscall-path EFAULT validation (defensive hardening, described in [compromising-xfails.md §2](compromising-xfails.md)) does not flip these XFAIL entries. -## 7. Summary: What Cannot Be Implemented in Wasm +## 7. Separate Process Memories Are Not Immediate Shared Memory + +Each Kandelo process owns a different WebAssembly `Memory`. Fork copies the +parent's bytes into a new shared memory for the child; it does not create one +linear memory that both PIDs can address. Kandelo therefore coordinates +anonymous `MAP_SHARED`, SysV SHM, and stable-identity regular-file mappings at +syscall boundaries: changed bytes are merged into a host-owned backing and peer +updates are imported when a process next enters the kernel. + +That closes file/data handoff cases but is not equivalent to shared physical +pages. Direct stores are not visible to another PID until a syscall boundary, +and a peer that only spins on loads does not import the update. Futex WAIT/WAKE +also operates on the caller's own `SharedArrayBuffer`, so process-shared +pthread mutexes and similar lock protocols remain unsupported. Threads created +with `CLONE_VM` do share one memory and are not subject to this cross-process +boundary. + +Regular-file `MAP_SHARED` has further explicit limits. The backend must provide +stable device/inode identity; current OPFS metadata reports no stable inode and +is rejected with `ENOTSUP`. In-kernel memfds also return `ENOTSUP` for shared +mappings until they have a mapping bridge. Bytes beyond EOF are zero-filled or +dropped rather than raising Linux's `SIGBUS`, and changes made by an external +host writer do not invalidate Kandelo's page cache. `MAP_PRIVATE` is unaffected +by the identity and memfd restrictions. + +## 8. Summary: What Cannot Be Implemented in Wasm | Feature | Why | |---------|-----| @@ -60,7 +85,7 @@ Until one of those ships, this remains a fundamental wasm limitation. The kernel | `munmap()` SIGSEGV-on-deref | Wasm linear memory cannot revoke page access — see §6 | | FP exceptions / alternate rounding | Wasm FP is non-trapping IEEE 754 with fixed round-to-nearest mode | | `getrusage()` with real data | No CPU/memory tracking available in Wasm runtime | -| `mremap()` | Wasm memory can only grow, not remap regions | +| Immediate cross-process `MAP_SHARED` + futex | Distinct process memories cannot directly address or wake on one another's bytes; Kandelo provides syscall-boundary data coherence instead | | Raw server sockets (browser) | Web sandbox prevents listening on ports | | Guest-initiated `pthread_create` | Wasm threads require host orchestration via Web Workers | | `pthread_cancel` | No architecture-specific `__syscall_cp_asm` for Wasm | @@ -69,17 +94,19 @@ Until one of those ships, this remains a fundamental wasm limitation. The kernel | Feature | Status | |---------|--------| -| `fork()` | `wasm-fork-instrument`-based, fully working | +| `fork()` | `wasm-fork-instrument` resumes supported main-thread, pthread, and direct main-to-one-side-module stacks; nested/opaque cross-side and pthread-inside-side-module paths remain unsupported | | `sigaltstack` | Shadow stack swap via inline asm (PR #174) | | `dlopen()` / `dlsym()` | Dynamic Wasm module linker (host/src/dylink.ts) | -| `exec()` / `posix_spawn()` | Full host-side exec with CWD resolution (PR #167, #178) | +| `exec()` | In-place host-side replacement with CWD resolution and stable mapping writeback handles; remaining descriptor and signal-attribution gaps are tracked in [posix-status.md](posix-status.md) | +| `mremap()` | Kernel range bookkeeping and in-memory move/grow/shrink are implemented; old bytes cannot be revoked after a move, for the same reason as `munmap()` | +| `posix_spawn()` | Non-forking host-side spawn with CWD resolution and file actions | | SysV IPC | Host-side handlers (PR #146) | | POSIX mqueues | Host-side handlers (PR #147) | | POSIX timers | setitimer/getitimer (PR #148) | | `sem_open` | Implemented | | PTY / terminal | Full pseudoterminal with line discipline (PR #181) | | Threads via `clone()` | Host-managed Web Workers, MariaDB runs 5 threads (PR #88) | -| OPFS filesystem | `host/src/vfs/opfs.ts` (browser persistence) | +| OPFS filesystem | `host/src/vfs/opfs.ts` provides browser persistence; current zero-inode metadata means regular-file `MAP_SHARED` returns `ENOTSUP` there | ## Current libc-test Results (2026-04-05) diff --git a/examples/chown_sentinel_test.c b/examples/chown_sentinel_test.c new file mode 100644 index 0000000000..0ff3df872f --- /dev/null +++ b/examples/chown_sentinel_test.c @@ -0,0 +1,181 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static int expect_ids(int fd, uid_t uid, gid_t gid, const char *step) +{ + struct stat st; + if (fstat(fd, &st) != 0) { + perror("fstat"); + return -1; + } + if (st.st_uid != uid || st.st_gid != gid) { + fprintf(stderr, "%s: got uid=%u gid=%u, expected uid=%u gid=%u\n", + step, (unsigned)st.st_uid, (unsigned)st.st_gid, + (unsigned)uid, (unsigned)gid); + return -1; + } + return 0; +} + +static int expect_path_ids(const char *path, int follow, uid_t uid, gid_t gid, + const char *step) +{ + struct stat st; + int rc = follow ? stat(path, &st) : lstat(path, &st); + if (rc != 0) { + perror(step); + return -1; + } + if (st.st_uid != uid || st.st_gid != gid) { + fprintf(stderr, "%s: got uid=%u gid=%u, expected uid=%u gid=%u\n", + step, (unsigned)st.st_uid, (unsigned)st.st_gid, + (unsigned)uid, (unsigned)gid); + return -1; + } + return 0; +} + +int main(void) +{ + const char *path = "/tmp/chown-sentinel"; + int fd = open(path, O_CREAT | O_RDWR | O_TRUNC, 0644); + if (fd < 0) { + perror("open"); + return 1; + } + + if (fchown(fd, 101, 202) != 0 || expect_ids(fd, 101, 202, "initial") != 0) + return 2; + if (fchown(fd, (uid_t)-1, 303) != 0 || + expect_ids(fd, 101, 303, "fchown uid sentinel") != 0) + return 3; + if (fchown(fd, 404, (gid_t)-1) != 0 || + expect_ids(fd, 404, 303, "fchown gid sentinel") != 0) + return 4; + if (chown(path, (uid_t)-1, 505) != 0 || + expect_ids(fd, 404, 505, "chown uid sentinel") != 0) + return 5; + if (fchownat(AT_FDCWD, path, 606, (gid_t)-1, 0) != 0 || + expect_ids(fd, 606, 505, "fchownat gid sentinel") != 0) + return 6; + if (chown(path, (uid_t)-1, (gid_t)-1) != 0 || + expect_ids(fd, 606, 505, "both sentinels") != 0) + return 7; + if (fchown(fd, 606, 505) != 0 || + expect_ids(fd, 606, 505, "explicit same ids") != 0) + return 8; + + errno = 0; + if (chown("/tmp/chown-sentinel-missing", (uid_t)-1, (gid_t)-1) != -1 || + errno != ENOENT) { + fprintf(stderr, "missing target did not return ENOENT: rc/errno=%d\n", errno); + return 9; + } + errno = 0; + if (fchown(9999, (uid_t)-1, (gid_t)-1) != -1 || errno != EBADF) { + fprintf(stderr, "invalid fd did not return EBADF: errno=%d\n", errno); + return 10; + } + if (futimens(fd, NULL) != 0) { + perror("futimens null pathname"); + return 33; + } + + const char *target = "/dev/shm/lchown-target"; + const char *link = "/tmp/lchown-link"; + const char *dangling = "/tmp/lchown-dangling"; + unlink(link); + unlink(dangling); + unlink(target); + int target_fd = open(target, O_CREAT | O_RDWR | O_TRUNC, 0644); + if (target_fd < 0 || fchown(target_fd, 111, 222) != 0) + return 17; + if (symlink(target, link) != 0 || lchown(link, 333, 444) != 0) + return 18; + if (expect_path_ids(link, 0, 333, 444, "lchown link") != 0 || + expect_path_ids(link, 1, 111, 222, "lchown target") != 0) + return 19; + + if (chown(link, 555, 666) != 0 || + expect_path_ids(link, 0, 333, 444, "chown preserves link") != 0 || + expect_path_ids(link, 1, 555, 666, "chown follows link") != 0) + return 20; + if (fchownat(AT_FDCWD, link, 777, 888, AT_SYMLINK_NOFOLLOW) != 0 || + expect_path_ids(link, 0, 777, 888, "fchownat nofollow link") != 0 || + expect_path_ids(link, 1, 555, 666, "fchownat preserves target") != 0) + return 21; + if (lchown(link, (uid_t)-1, 889) != 0 || + expect_path_ids(link, 0, 777, 889, "lchown uid sentinel") != 0) + return 22; + + if (symlink("/tmp/lchown-missing", dangling) != 0 || + lchown(dangling, 901, 902) != 0 || + expect_path_ids(dangling, 0, 901, 902, "dangling lchown") != 0) + return 23; + errno = 0; + if (chown(dangling, 1, 2) != -1 || errno != ENOENT) + return 24; + + if (mkdir("/tmp/lchown-dir", 0755) != 0 && errno != EEXIST) + return 25; + int dirfd = open("/tmp/lchown-dir", O_RDONLY | O_DIRECTORY); + if (dirfd < 0) + return 26; + unlink("/tmp/lchown-dir/relative-link"); + if (symlink(target, "/tmp/lchown-dir/relative-link") != 0 || + fchownat(dirfd, "relative-link", 903, 904, AT_SYMLINK_NOFOLLOW) != 0 || + expect_path_ids("/tmp/lchown-dir/relative-link", 0, 903, 904, + "relative fchownat nofollow") != 0 || + expect_path_ids(target, 1, 555, 666, "relative preserves target") != 0) + return 27; + close(dirfd); + + errno = 0; + if (fchownat(AT_FDCWD, link, 1, 2, 0x200) != -1 || errno != EINVAL) + return 28; + errno = 0; + if (syscall(SYS_lchown, (const char *)0, 1, 2) != -1 || errno != EFAULT) + return 29; + errno = 0; + if (syscall(SYS_lchown, (const char *)(uintptr_t)-1, 1, 2) != -1 || + errno != EFAULT) + return 30; + char *unterminated = malloc(65536); + if (unterminated == NULL) + return 31; + memset(unterminated, 'x', 65536); + errno = 0; + if (syscall(SYS_lchown, unterminated, 1, 2) != -1 || + errno != ENAMETOOLONG) { + free(unterminated); + return 32; + } + free(unterminated); + close(target_fd); + + if (chown(path, 999, 505) != 0 || seteuid(999) != 0) + return 11; + if (chown(path, (uid_t)-1, (gid_t)-1) != 0) + return 12; + if (seteuid(0) != 0 || seteuid(777) != 0) + return 13; + errno = 0; + if (chown(path, 999, 505) != -1 || errno != EPERM) + return 14; + errno = 0; + if (chown(path, (uid_t)-1, 505) != -1 || errno != EPERM) + return 15; + if (seteuid(0) != 0) + return 16; + + close(fd); + puts("CHOWN_SENTINEL_PASS"); + return 0; +} diff --git a/examples/clock_getcpuclockid_test.c b/examples/clock_getcpuclockid_test.c new file mode 100644 index 0000000000..a46c1d1539 --- /dev/null +++ b/examples/clock_getcpuclockid_test.c @@ -0,0 +1,34 @@ +#include +#include +#include + +int main(void) { + clockid_t clock_id = 0; + struct timespec ts = {0}; + + /* POSIX returns the error number directly from clock_getcpuclockid(). */ + int rc = clock_getcpuclockid(-2, &clock_id); + if (rc != ESRCH) { + fprintf(stderr, + "FAIL clock_getcpuclockid(-2): rc=%d expected=%d clock=%d\n", + rc, ESRCH, (int)clock_id); + return 1; + } + + errno = 0; + if (clock_gettime((clockid_t)10, &ts) != -1 || errno != EINVAL) { + fprintf(stderr, "FAIL clock_gettime(10): errno=%d expected=%d\n", + errno, EINVAL); + return 1; + } + + errno = 0; + if (clock_getres((clockid_t)10, &ts) != -1 || errno != EINVAL) { + fprintf(stderr, "FAIL clock_getres(10): errno=%d expected=%d\n", + errno, EINVAL); + return 1; + } + + puts("PASS clock id validation"); + return 0; +} diff --git a/examples/environment_lifecycle_test.c b/examples/environment_lifecycle_test.c new file mode 100644 index 0000000000..19c981a5dd --- /dev/null +++ b/examples/environment_lifecycle_test.c @@ -0,0 +1,134 @@ +#include +#include +#include +#include +#include +#include +#include + +static int kernel_env_equals(const char *name, const char *expected) +{ + char buf[256]; + errno = 0; + long len = syscall(SYS_getenv, name, buf, sizeof(buf)); + if (expected == NULL) + return len == -1 && errno == ENOENT; + size_t expected_len = strlen(expected); + return len == (long)expected_len && + memcmp(buf, expected, expected_len) == 0; +} + +static int environment_equals(const char *name, const char *expected) +{ + const char *value = getenv(name); + if (expected == NULL) { + if (value != NULL) + return 0; + } else if (value == NULL || strcmp(value, expected) != 0) { + return 0; + } + return kernel_env_equals(name, expected); +} + +static int exec_replacement(void) +{ + if (!environment_equals("REPLACED", "exec") || + !environment_equals("INITIAL", NULL) || + !environment_equals("MUTATED", NULL) || + !environment_equals("REMOVE", NULL)) { + fprintf(stderr, "exec environment was merged instead of replaced\n"); + return 20; + } + puts("EXEC_ENV_PASS"); + fflush(stdout); + + char *argv[] = { "/bin/environment-lifecycle", "empty", NULL }; + char *envp[] = { NULL }; + execve(argv[0], argv, envp); + perror("execve empty environment"); + return 21; +} + +static int empty_replacement(void) +{ + if (!environment_equals("REPLACED", NULL) || + !environment_equals("INITIAL", NULL) || + !environment_equals("MUTATED", NULL) || + !environment_equals("REMOVE", NULL)) { + fprintf(stderr, "empty exec environment retained an old entry\n"); + return 30; + } + puts("EMPTY_ENV_PASS"); + return 0; +} + +int main(int argc, char **argv) +{ + if (argc == 2 && strcmp(argv[1], "exec") == 0) + return exec_replacement(); + if (argc == 2 && strcmp(argv[1], "empty") == 0) + return empty_replacement(); + + if (!environment_equals("INITIAL", "parent") || + !environment_equals("REMOVE", "before-fork")) { + fprintf(stderr, "initial guest and kernel environments diverged\n"); + return 1; + } + if (setenv("MUTATED", "before-fork", 1) != 0 || unsetenv("REMOVE") != 0) { + perror("prepare fork environment"); + return 2; + } + + int gate[2]; + if (pipe(gate) != 0) { + perror("pipe"); + return 3; + } + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return 4; + } + if (pid == 0) { + close(gate[1]); + char byte; + if (read(gate[0], &byte, 1) != 1) { + perror("child gate read"); + _exit(10); + } + close(gate[0]); + if (!environment_equals("INITIAL", "parent") || + !environment_equals("MUTATED", "before-fork") || + !environment_equals("REMOVE", NULL)) { + fprintf(stderr, "fork child environment was not an isolated copy\n"); + _exit(11); + } + puts("FORK_ENV_PASS"); + fflush(stdout); + _exit(0); + } + + close(gate[0]); + if (setenv("MUTATED", "parent-after-fork", 1) != 0) { + perror("parent setenv"); + return 5; + } + if (write(gate[1], "x", 1) != 1) { + perror("parent gate write"); + return 6; + } + close(gate[1]); + + int status = 0; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) { + fprintf(stderr, "fork child failed: status=%d\n", status); + return 7; + } + + char *exec_argv[] = { "/bin/environment-lifecycle", "exec", NULL }; + char *exec_envp[] = { "REPLACED=exec", NULL }; + execve(exec_argv[0], exec_argv, exec_envp); + perror("execve replacement environment"); + return 8; +} diff --git a/examples/getpwent_smoke.c b/examples/getpwent_smoke.c index 9e32c948d8..6d01d4ea56 100644 --- a/examples/getpwent_smoke.c +++ b/examples/getpwent_smoke.c @@ -83,6 +83,21 @@ static int check_servbyname(const char *name, const char *proto, int expect_port return port == (unsigned)expect_port ? 0 : 1; } +static int check_service_query(const char *query, const char *proto, + const char *expect_name, int expect_port) { + struct servent *se = getservbyname(query, proto); + if (se == NULL) { + printf("SERVENT query=%s proto=%s result=NULL\n", query, proto); + return 1; + } + unsigned short port = ((unsigned short)se->s_port << 8) | + ((unsigned short)se->s_port >> 8); + printf("SERVENT query=%s name=%s proto=%s port=%u\n", + query, se->s_name, proto, (unsigned)port); + return (strcmp(se->s_name, expect_name) == 0 && + port == (unsigned)expect_port) ? 0 : 1; +} + int main(void) { int rc = 0; rc |= check_pwent_iteration(); @@ -94,6 +109,11 @@ int main(void) { rc |= check_grent_iteration(); rc |= check_servbyname("ssh", "tcp", 22); rc |= check_servbyname("http", "tcp", 80); + rc |= check_service_query("www", "tcp", "www", 80); + rc |= check_service_query("www-http", "tcp", "www-http", 80); + rc |= check_service_query("https", "tcp", "https", 443); + rc |= check_service_query("mysql", "tcp", "mysql", 3306); + rc |= check_service_query("postgresql", "tcp", "postgresql", 5432); printf("DONE rc=%d\n", rc); return rc; } 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/mmap_shared_anonymous_fork.c b/examples/mmap_shared_anonymous_fork.c new file mode 100644 index 0000000000..b876abfa28 --- /dev/null +++ b/examples/mmap_shared_anonymous_fork.c @@ -0,0 +1,84 @@ +#include +#include +#include +#include +#include + +static int wait_ok(pid_t pid) { + int status = 0; + if (waitpid(pid, &status, 0) < 0) { + perror("waitpid"); + return 0; + } + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) { + fprintf(stderr, "child failed: status=%d\n", status); + return 0; + } + return 1; +} + +int main(void) { + const long page_size = sysconf(_SC_PAGESIZE); + if (page_size <= 0) { + perror("sysconf"); + return 1; + } + + char *shared = mmap( + NULL, + (size_t)page_size, + PROT_READ | PROT_WRITE, + MAP_SHARED | MAP_ANONYMOUS, + -1, + 0 + ); + if (shared == MAP_FAILED) { + perror("mmap"); + return 1; + } + + shared[0] = 'A'; + pid_t pid = fork(); + if (pid < 0) { + perror("fork"); + return 1; + } + if (pid == 0) { + if (shared[0] != 'A') { + fprintf(stderr, "child did not see parent write: %c\n", shared[0]); + _exit(2); + } + shared[0] = 'B'; + _exit(0); + } + if (!wait_ok(pid)) return 1; + if (shared[0] != 'B') { + fprintf(stderr, "parent did not see child write: %c\n", shared[0]); + return 1; + } + printf("inherited anonymous mapping coherent\n"); + + shared[1] = 'C'; + pid = fork(); + if (pid < 0) { + perror("fork second"); + return 1; + } + if (pid == 0) { + shared[1] = 'D'; + _exit(0); + } + if (!wait_ok(pid)) return 1; + if (shared[1] != 'D') { + fprintf(stderr, "parent did not see second child write: %c\n", shared[1]); + return 1; + } + printf("reused anonymous backing coherent\n"); + + if (munmap(shared, (size_t)page_size) < 0) { + perror("munmap"); + return 1; + } + printf("PASS\n"); + return 0; +} diff --git a/examples/mount_probe_test.c b/examples/mount_probe_test.c index 558143338d..5eba791175 100644 --- a/examples/mount_probe_test.c +++ b/examples/mount_probe_test.c @@ -13,6 +13,9 @@ * (proves VirtualPlatformIO's VFS-only-lens has no * fallthrough — unmounted paths must hit ENOENT) * + * path-resolution exercise component-wise symlink, dot-dot, and mount + * crossing behavior below the supplied scratch path + * * Output format is one machine-parseable line per probe so the host test * can assert on substrings without compiling-in C-specific marshalling. */ @@ -99,11 +102,106 @@ static int probe_unmounted(const char *path) { return 0; } +static int join_path(char *out, size_t out_len, const char *base, const char *suffix) { + int n = snprintf(out, out_len, "%s/%s", base, suffix); + return n > 0 && (size_t)n < out_len ? 0 : -1; +} + +static int probe_path_resolution(const char *base) { + char target[512], child[512], link[512], link_parent[512]; + char missing_walk[512], regular[512], regular_dot[512]; + char etc_link[512], services_via_link[512], cwd[512]; + if (join_path(target, sizeof(target), base, "target") < 0 || + join_path(child, sizeof(child), target, "child") < 0 || + join_path(link, sizeof(link), base, "link") < 0 || + join_path(link_parent, sizeof(link_parent), link, "..") < 0 || + join_path(missing_walk, sizeof(missing_walk), base, "missing/../target") < 0 || + join_path(regular, sizeof(regular), base, "regular") < 0 || + join_path(regular_dot, sizeof(regular_dot), regular, ".") < 0 || + join_path(etc_link, sizeof(etc_link), base, "etc-link") < 0 || + join_path(services_via_link, sizeof(services_via_link), etc_link, "services") < 0) { + printf("PATH_RESOLUTION path-too-long\n"); + return 1; + } + + /* Make the probe repeatable after an interrupted prior run. */ + unlink(link); + unlink(etc_link); + unlink(regular); + rmdir(child); + rmdir(target); + rmdir(base); + + if (mkdir(base, 0700) < 0 || mkdir(target, 0700) < 0 || mkdir(child, 0700) < 0) { + printf("PATH_RESOLUTION mkdir-errno=%d\n", errno); + return 1; + } + if (symlink("target/child", link) < 0) { + printf("PATH_RESOLUTION symlink-errno=%d\n", errno); + return 1; + } + if (chdir(link_parent) < 0 || getcwd(cwd, sizeof(cwd)) == NULL) { + printf("PATH_RESOLUTION chdir-errno=%d\n", errno); + return 1; + } + if (strcmp(cwd, target) != 0) { + printf("PATH_RESOLUTION cwd=%s expected=%s\n", cwd, target); + return 1; + } + if (chdir("/") < 0) { + printf("PATH_RESOLUTION root-chdir-errno=%d\n", errno); + return 1; + } + + struct stat st; + errno = 0; + if (stat(missing_walk, &st) == 0 || errno != ENOENT) { + printf("PATH_RESOLUTION missing-dotdot-errno=%d expected=%d\n", errno, ENOENT); + return 1; + } + + int fd = open(regular, O_CREAT | O_WRONLY | O_TRUNC, 0600); + if (fd < 0) { + printf("PATH_RESOLUTION regular-open-errno=%d\n", errno); + return 1; + } + close(fd); + errno = 0; + if (stat(regular_dot, &st) == 0 || errno != ENOTDIR) { + printf("PATH_RESOLUTION regular-dot-errno=%d expected=%d\n", errno, ENOTDIR); + return 1; + } + + if (symlink("/etc", etc_link) < 0) { + printf("PATH_RESOLUTION cross-mount-symlink-errno=%d\n", errno); + return 1; + } + struct stat direct_services; + if (stat(services_via_link, &st) < 0 || stat("/etc/services", &direct_services) < 0) { + printf("PATH_RESOLUTION cross-mount-stat-errno=%d\n", errno); + return 1; + } + if (st.st_size != direct_services.st_size) { + printf("PATH_RESOLUTION cross-mount-size=%lld expected=%lld\n", + (long long)st.st_size, (long long)direct_services.st_size); + return 1; + } + + printf("PATH_RESOLUTION_PASS cwd=%s services=%lld\n", + target, (long long)st.st_size); + return 0; +} + int main(int argc, char **argv) { - if (argc < 3) { + if (argc < 2) { fprintf(stderr, "usage: %s \n", argv[0]); return 2; } + if (strcmp(argv[1], "path-resolution") == 0) { + if (argc < 3) return 2; + return probe_path_resolution(argv[2]); + } + if (argc < 3) return 2; if (strcmp(argv[1], "rootfs") == 0) return probe_rootfs(argv[2]); if (strcmp(argv[1], "scratch") == 0) return probe_scratch(argv[2]); if (strcmp(argv[1], "unmounted") == 0) return probe_unmounted(argv[2]); diff --git a/examples/pathconf_test.c b/examples/pathconf_test.c new file mode 100644 index 0000000000..3cb1742cbd --- /dev/null +++ b/examples/pathconf_test.c @@ -0,0 +1,106 @@ +#include +#include +#include +#include +#include +#include +#include + +#ifdef FILESIZEBITS +#error "FILESIZEBITS must remain undefined when the maximum varies by backend" +#endif + +#if _POSIX_ASYNCHRONOUS_IO <= 0 +#error "thread-backed asynchronous I/O must remain advertised" +#endif + +#if _POSIX_PRIORITIZED_IO != -1 +#error "prioritized I/O is not supported" +#endif + +#if _POSIX_SYNCHRONIZED_IO != -1 +#error "synchronized I/O is not supported" +#endif + +static int check(int condition, const char *message) +{ + if (condition) return 0; + fprintf(stderr, "pathconf failure: %s (errno=%d)\n", message, errno); + return 1; +} + +int main(void) +{ + int failed = 0; + long value; + + errno = E2BIG; + value = pathconf("/", _PC_PATH_MAX); + failed |= check(value == 4096 && errno == E2BIG, + "finite pathconf result preserves errno"); + + errno = E2BIG; + value = pathconf("/", _PC_LINK_MAX); + failed |= check(value == -1 && errno == E2BIG, + "indeterminate pathconf result preserves errno"); + + errno = 0; + value = pathconf("/definitely-missing", _PC_PATH_MAX); + failed |= check(value == -1 && errno == ENOENT, + "missing path reports ENOENT"); + + errno = 0; + value = pathconf("/", 999); + failed |= check(value == -1 && errno == EINVAL, + "invalid name reports EINVAL"); + + errno = 0; + value = fpathconf(-1, _PC_PATH_MAX); + failed |= check(value == -1 && errno == EBADF, + "invalid descriptor reports EBADF"); + + const char *path = "/tmp/pathconf-test"; + int fd = open(path, O_CREAT | O_RDWR | O_TRUNC, 0600); + failed |= check(fd >= 0, "create test file"); + if (fd >= 0) { + failed |= check(pathconf(path, _PC_ASYNC_IO) > 0, + "regular pathname reports asynchronous I/O"); + failed |= check(unlink(path) == 0, "unlink open test file"); + errno = E2BIG; + value = fpathconf(fd, _PC_PATH_MAX); + failed |= check(value == 4096 && errno == E2BIG, + "fpathconf uses live descriptor after unlink"); + failed |= check(fpathconf(fd, _PC_ASYNC_IO) > 0, + "regular descriptor reports asynchronous I/O"); + close(fd); + } + + unsigned char unaligned_storage[16] = {0}; + long raw = syscall(SYS_pathconf, "/", _PC_PATH_MAX, + unaligned_storage + 1); + int64_t raw_value = 0; + memcpy(&raw_value, unaligned_storage + 1, sizeof(raw_value)); + failed |= check(raw == 0 && raw_value == 4096, + "unaligned raw output pointer succeeds"); + + errno = 0; + raw = syscall(SYS_pathconf, "/", _PC_PATH_MAX, (void *)0); + failed |= check(raw == -1 && errno == EFAULT, + "null raw output pointer reports EFAULT"); + + for (uintptr_t remaining = 1; remaining < sizeof(int64_t); remaining++) { + uintptr_t memory_end = + (uintptr_t)__builtin_wasm_memory_size(0) * 65536u; + errno = 0; + raw = syscall(SYS_pathconf, "/", _PC_PATH_MAX, + (void *)(memory_end - remaining)); + failed |= check(raw == -1 && errno == EFAULT, + "out-of-range raw output pointer reports EFAULT"); + failed |= check(getpid() > 0, + "worker remains live after rejected output pointer"); + } + + if (failed) return 1; + puts("PATHCONF_PASS"); + return 0; +} diff --git a/examples/pthread_channel_reuse_test.c b/examples/pthread_channel_reuse_test.c new file mode 100644 index 0000000000..6bcddca693 --- /dev/null +++ b/examples/pthread_channel_reuse_test.c @@ -0,0 +1,63 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include + +static void *mark_started(void *opaque) +{ + atomic_store_explicit((atomic_int *)opaque, 1, memory_order_release); + return NULL; +} + +static int run_thread(atomic_int *started) +{ + pthread_t thread; + int error = pthread_create(&thread, NULL, mark_started, started); + if (error != 0) { + fprintf(stderr, "pthread_create failed: %d\n", error); + return -1; + } + error = pthread_join(thread, NULL); + if (error != 0) { + fprintf(stderr, "pthread_join failed: %d\n", error); + return -1; + } + if (!atomic_load_explicit(started, memory_order_acquire)) { + fputs("pthread callback did not run\n", stderr); + return -1; + } + return 0; +} + +int main(void) +{ + atomic_int first_started = 0; + atomic_int second_started = 0; + + if (run_thread(&first_started) != 0) + return 1; + + pid_t child = fork(); + if (child < 0) { + perror("fork"); + return 2; + } + if (child == 0) + _exit(0); + + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) { + fprintf(stderr, "child status mismatch: %#x\n", status); + return 3; + } + + if (run_thread(&second_started) != 0) + return 4; + + puts("PTHREAD_CHANNEL_REUSE_PASS"); + return 0; +} diff --git a/examples/rlimit_fsize_test.c b/examples/rlimit_fsize_test.c new file mode 100644 index 0000000000..7117e10291 --- /dev/null +++ b/examples/rlimit_fsize_test.c @@ -0,0 +1,372 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static volatile sig_atomic_t sigxfsz_count; + +static void on_sigxfsz(int signo) +{ + (void)signo; + sigxfsz_count++; +} + +static int set_fsize_limit(rlim_t limit) +{ + struct rlimit value = { + .rlim_cur = limit, + .rlim_max = RLIM_INFINITY, + }; + return setrlimit(RLIMIT_FSIZE, &value); +} + +static int open_test_file(const char *path, int flags) +{ + int fd = open(path, flags | O_CREAT | O_TRUNC, 0600); + if (fd < 0) + perror(path); + return fd; +} + +static int fail(const char *step) +{ + fprintf(stderr, "RLIMIT_FSIZE_FAIL: %s (errno=%d signals=%d)\n", + step, errno, (int)sigxfsz_count); + return 1; +} + +static int test_scalar_and_large_writes(void) +{ + int fd = open_test_file("/tmp/fsize-scalar", O_WRONLY); + if (fd < 0 || set_fsize_limit(5) != 0) + return fail("scalar setup"); + + sigxfsz_count = 0; + if (write(fd, "abcdefgh", 8) != 5 || sigxfsz_count != 0) + return fail("scalar crossing write"); + if (write(fd, "", 0) != 0 || sigxfsz_count != 0) + return fail("zero write at limit"); + errno = 0; + if (write(fd, "x", 1) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("scalar write at limit"); + close(fd); + + char *large = malloc(65537); + if (!large) + return fail("large allocation"); + memset(large, 'L', 65537); + + fd = open_test_file("/tmp/fsize-large-write", O_WRONLY); + if (fd < 0 || set_fsize_limit(65536) != 0) + return fail("large write setup"); + sigxfsz_count = 0; + if (write(fd, large, 65537) != 65536 || sigxfsz_count != 0) + return fail("64KiB write operation boundary"); + errno = 0; + if (write(fd, large, 65537) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("large write preflight at limit"); + close(fd); + + fd = open_test_file("/tmp/fsize-large-pwrite", O_WRONLY); + if (fd < 0 || set_fsize_limit(65536) != 0) + return fail("large pwrite setup"); + sigxfsz_count = 0; + if (pwrite(fd, large, 65537, 0) != 65536 || sigxfsz_count != 0) + return fail("64KiB pwrite operation boundary"); + errno = 0; + if (pwrite(fd, large, 65537, 65536) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("large pwrite preflight at limit"); + close(fd); + + struct iovec large_iov[2] = { + { .iov_base = large, .iov_len = 65536 }, + { .iov_base = large + 65536, .iov_len = 1 }, + }; + fd = open_test_file("/tmp/fsize-large-writev", O_WRONLY); + if (fd < 0 || set_fsize_limit(65536) != 0) + return fail("large writev setup"); + sigxfsz_count = 0; + if (writev(fd, large_iov, 2) != 65536 || sigxfsz_count != 0) + return fail("large writev host decomposition"); + errno = 0; + if (writev(fd, large_iov, 2) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("large writev preflight at limit"); + close(fd); + + fd = open_test_file("/tmp/fsize-large-pwritev", O_WRONLY); + if (fd < 0 || set_fsize_limit(65536) != 0) + return fail("large pwritev setup"); + sigxfsz_count = 0; + if (pwritev(fd, large_iov, 2, 0) != 65536 || sigxfsz_count != 0) + return fail("large pwritev host decomposition"); + errno = 0; + if (pwritev(fd, large_iov, 2, 65536) != -1 || errno != EFBIG || + sigxfsz_count != 1) + return fail("large pwritev preflight at limit"); + close(fd); + + // The slow pwritev host path receives the offset as split low/high words. + // A low word with bit 31 set must remain unsigned during reconstruction. + fd = open_test_file("/tmp/fsize-pwritev-split", O_WRONLY); + off_t split_offset = (off_t)0xffffffffULL; + if (fd < 0 || set_fsize_limit((rlim_t)split_offset) != 0) + return fail("split-offset pwritev setup"); + sigxfsz_count = 0; + errno = 0; + if (pwritev(fd, large_iov, 2, split_offset) != -1 || errno != EFBIG || + sigxfsz_count != 1) + return fail("split-offset pwritev preflight"); + close(fd); + +#if __SIZEOF_POINTER__ == 8 + fd = open_test_file("/tmp/fsize-unreportable-count", O_WRONLY); + if (fd < 0 || set_fsize_limit(RLIM_INFINITY) != 0) + return fail("unreportable count setup"); + errno = 0; + if (syscall(SYS_write, fd, large, (size_t)INT32_MAX + 1) != -1 || + errno != EINVAL) + return fail("unreportable wasm64 write count"); + close(fd); +#endif + + free(large); + return 0; +} + +static int test_vectors_and_large_limit(void) +{ + struct iovec iov[3] = { + { .iov_base = (void *)"ab", .iov_len = 2 }, + { .iov_base = (void *)"cde", .iov_len = 3 }, + { .iov_base = (void *)"f", .iov_len = 1 }, + }; + int fd = open_test_file("/tmp/fsize-writev", O_WRONLY); + if (fd < 0 || set_fsize_limit(5) != 0) + return fail("writev setup"); + sigxfsz_count = 0; + if (writev(fd, iov, 3) != 5 || sigxfsz_count != 0) + return fail("writev exact iovec boundary"); + struct iovec next = { .iov_base = (void *)"x", .iov_len = 1 }; + errno = 0; + if (writev(fd, &next, 1) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("writev next operation"); + close(fd); + + fd = open_test_file("/tmp/fsize-pwritev", O_WRONLY); + if (fd < 0 || set_fsize_limit(5) != 0) + return fail("pwritev setup"); + sigxfsz_count = 0; + if (pwritev(fd, iov, 3, 0) != 5 || sigxfsz_count != 0) + return fail("pwritev exact iovec boundary"); + errno = 0; + if (pwritev(fd, &next, 1, 5) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("pwritev next operation"); + close(fd); + + // The kernel is wasm32 for both guest architectures. This small write + // catches a remaining-budget conversion that wraps modulo 2^32. + fd = open_test_file("/tmp/fsize-large-limit", O_WRONLY); + rlim_t large_limit = ((rlim_t)1 << 32) + 5; + if (fd < 0 || set_fsize_limit(large_limit) != 0) + return fail("large limit setup"); + sigxfsz_count = 0; + if (write(fd, "0123456789", 10) != 10 || sigxfsz_count != 0) + return fail("limit above wasm32 range"); + close(fd); + return 0; +} + +static int test_memfd_truncate_and_nonfiles(void) +{ + int fd = memfd_create("fsize", 0); + if (fd < 0 || ftruncate(fd, 20) != 0 || set_fsize_limit(10) != 0) + return fail("memfd setup"); + sigxfsz_count = 0; + if (ftruncate(fd, 15) != 0 || sigxfsz_count != 0) + return fail("memfd shrink above limit"); + if (ftruncate(fd, 0) != 0 || set_fsize_limit(5) != 0) + return fail("memfd reset"); + sigxfsz_count = 0; + if (write(fd, "abcdefgh", 8) != 5 || sigxfsz_count != 0) + return fail("memfd crossing write"); + errno = 0; + if (pwrite(fd, "x", 1, 5) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("memfd positioned limit"); + sigxfsz_count = 0; + errno = 0; + if (ftruncate(fd, 6) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("memfd truncate limit"); + sigxfsz_count = 0; + errno = 0; + if (fallocate(fd, 0, 5, 1) != -1 || errno != EFBIG || sigxfsz_count != 1) + return fail("memfd fallocate limit"); + close(fd); + + int pipefd[2]; + if (pipe(pipefd) != 0 || set_fsize_limit(1) != 0) + return fail("pipe setup"); + sigxfsz_count = 0; + if (write(pipefd[1], "abc", 3) != 3 || sigxfsz_count != 0) + return fail("pipe unaffected"); + char data[3]; + if (read(pipefd[0], data, sizeof(data)) != 3 || memcmp(data, "abc", 3) != 0) + return fail("pipe readback"); + close(pipefd[0]); + close(pipefd[1]); + + char *large = malloc(65537); + if (!large) + return fail("closed-pipe vector allocation"); + memset(large, 'P', 65537); + struct iovec large_iov = { .iov_base = large, .iov_len = 65537 }; + if (pipe(pipefd) != 0 || set_fsize_limit(RLIM_INFINITY) != 0) + return fail("closed-pipe vector setup"); + close(pipefd[0]); + sigxfsz_count = 0; + errno = 0; + if (writev(pipefd[1], &large_iov, 1) != -1 || errno != EPIPE || + sigxfsz_count != 1) + return fail("closed-pipe vector preserves EPIPE"); + close(pipefd[1]); + free(large); + return 0; +} + +static volatile int thread_phase; +static int thread_fd; +static int worker_errno; +static ssize_t worker_result; + +static void *blocked_writer(void *unused) +{ + (void)unused; + sigset_t set; + sigemptyset(&set); + sigaddset(&set, SIGXFSZ); + pthread_sigmask(SIG_BLOCK, &set, NULL); + errno = 0; + worker_result = write(thread_fd, "x", 1); + worker_errno = errno; + __atomic_store_n(&thread_phase, 1, __ATOMIC_RELEASE); + while (__atomic_load_n(&thread_phase, __ATOMIC_ACQUIRE) != 2) { + } + + pthread_sigmask(SIG_UNBLOCK, &set, NULL); + (void)syscall(SYS_getpid); + __atomic_store_n(&thread_phase, 3, __ATOMIC_RELEASE); + return NULL; +} + +static void *unblocked_observer(void *unused) +{ + (void)unused; + sigset_t set; + sigemptyset(&set); + sigaddset(&set, SIGXFSZ); + pthread_sigmask(SIG_UNBLOCK, &set, NULL); + __atomic_store_n(&thread_phase, 1, __ATOMIC_RELEASE); + while (__atomic_load_n(&thread_phase, __ATOMIC_ACQUIRE) != 2) { + } + (void)syscall(SYS_getpid); + __atomic_store_n(&thread_phase, 3, __ATOMIC_RELEASE); + return NULL; +} + +static int test_sigxfsz_targets_calling_thread(void) +{ + thread_fd = open_test_file("/tmp/fsize-thread", O_WRONLY); + int wake_pipe[2]; + if (thread_fd < 0 || pipe(wake_pipe) != 0 || set_fsize_limit(0) != 0) + return fail("thread signal setup"); + + sigxfsz_count = 0; + thread_phase = 0; + pthread_t thread; + if (pthread_create(&thread, NULL, blocked_writer, NULL) != 0) + return fail("pthread_create"); + while (__atomic_load_n(&thread_phase, __ATOMIC_ACQUIRE) != 1) { + } + + if (worker_result != -1 || worker_errno != EFBIG || sigxfsz_count != 0) + return fail("blocked worker write"); + + // A process-shared pending signal would be delivered to this unblocked + // main thread at the next syscall. A thread-directed signal stays with the + // blocked writer until that same thread unmasks it. + if (write(wake_pipe[1], "w", 1) != 1 || sigxfsz_count != 0) + return fail("signal escaped to main thread"); + + __atomic_store_n(&thread_phase, 2, __ATOMIC_RELEASE); + while (__atomic_load_n(&thread_phase, __ATOMIC_ACQUIRE) != 3) { + } + if (pthread_join(thread, NULL) != 0) + return fail("pthread_join"); + if (sigxfsz_count != 1) + return fail("signal did not return to writer thread"); + + // Check the inverse direction. A SIGXFSZ generated while the main thread + // blocks it must not be visible to an unblocked worker. + sigset_t set; + sigemptyset(&set); + sigaddset(&set, SIGXFSZ); + sigxfsz_count = 0; + thread_phase = 0; + if (pthread_create(&thread, NULL, unblocked_observer, NULL) != 0) + return fail("observer pthread_create"); + while (__atomic_load_n(&thread_phase, __ATOMIC_ACQUIRE) != 1) { + } + if (pthread_sigmask(SIG_BLOCK, &set, NULL) != 0) + return fail("main signal block"); + errno = 0; + if (write(thread_fd, "x", 1) != -1 || errno != EFBIG || sigxfsz_count != 0) + return fail("blocked main write"); + __atomic_store_n(&thread_phase, 2, __ATOMIC_RELEASE); + while (__atomic_load_n(&thread_phase, __ATOMIC_ACQUIRE) != 3) { + } + if (sigxfsz_count != 0) + return fail("signal escaped to worker thread"); + if (pthread_sigmask(SIG_UNBLOCK, &set, NULL) != 0) + return fail("main signal unblock"); + (void)syscall(SYS_getpid); + if (pthread_join(thread, NULL) != 0) + return fail("observer pthread_join"); + if (sigxfsz_count != 1) + return fail("signal did not return to main thread"); + + close(wake_pipe[0]); + close(wake_pipe[1]); + close(thread_fd); + return 0; +} + +int main(void) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = on_sigxfsz; + sigemptyset(&action.sa_mask); + if (sigaction(SIGXFSZ, &action, NULL) != 0) + return fail("sigaction"); + if (sigaction(SIGPIPE, &action, NULL) != 0) + return fail("sigpipe sigaction"); + + if (test_scalar_and_large_writes() != 0 || + test_vectors_and_large_limit() != 0 || + test_memfd_truncate_and_nonfiles() != 0 || + test_sigxfsz_targets_calling_thread() != 0) + return 1; + + puts("RLIMIT_FSIZE_PASS"); + return 0; +} diff --git a/examples/run-example-paths.ts b/examples/run-example-paths.ts new file mode 100644 index 0000000000..28a20f126e --- /dev/null +++ b/examples/run-example-paths.ts @@ -0,0 +1,16 @@ +import { realpathSync } from "node:fs"; +import { isAbsolute, relative } from "node:path"; + +/** + * Return whether an existing candidate is physically contained by an existing + * parent directory. + * + * The Node-backed guest VFS canonicalizes host paths. Compare canonical paths + * here as well so aliases such as macOS's /var -> /private/var do not turn an + * in-workdir guest executable into an apparent escape. Canonicalizing both + * sides also prevents a symlink below the workdir from escaping this boundary. + */ +export function isWithinRealDirectory(parent: string, candidate: string): boolean { + const rel = relative(realpathSync(parent), realpathSync(candidate)); + return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel)); +} diff --git a/examples/run-example.ts b/examples/run-example.ts index 4ef23dcbb0..93fbbcdbf4 100644 --- a/examples/run-example.ts +++ b/examples/run-example.ts @@ -12,10 +12,11 @@ * npx tsx examples/run-example.ts /path/to/test.wasm */ -import { readFileSync, existsSync, statSync } from "fs"; -import { resolve, dirname, isAbsolute, relative } from "path"; +import { closeSync, existsSync, openSync, readFileSync, statSync, writeSync } from "fs"; +import { resolve, dirname, isAbsolute } from "path"; import { NodeKernelHost } from "../host/src/node-kernel-host"; import { tryResolveBinary } from "../host/src/binary-resolver"; +import { isWithinRealDirectory } from "./run-example-paths"; const repoRoot = resolve(dirname(new URL(import.meta.url).pathname), ".."); @@ -253,11 +254,6 @@ function loadBytes(path: string): ArrayBuffer { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } -function isWithinDirectory(parent: string, candidate: string): boolean { - const rel = relative(resolve(parent), resolve(candidate)); - return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel)); -} - function tryLoadGuestCandidate(candidate: string, kernelCwd: string): ArrayBuffer | null { const resolved = resolve(candidate); if (!existsSync(resolved)) return null; @@ -265,11 +261,11 @@ function tryLoadGuestCandidate(candidate: string, kernelCwd: string): ArrayBuffe // Guest exec resolution may read scripts and test binaries staged under // KERNEL_CWD. Outside that guest workdir, only explicit .wasm paths are // valid candidates; never treat host /usr/bin tools as guest programs. - if (!isWithinDirectory(kernelCwd, resolved) && !resolved.endsWith(".wasm")) { - return null; - } - try { + if (!resolved.endsWith(".wasm") && + !isWithinRealDirectory(kernelCwd, resolved)) { + return null; + } if (!statSync(resolved).isFile()) return null; return loadBytes(resolved); } catch { @@ -301,7 +297,11 @@ function resolveProgram(path: string): ArrayBuffer | null { function guestEnv(): string[] { const kernelPath = process.env.KERNEL_PATH ?? "/usr/local/bin:/usr/bin:/bin"; const inherited = Object.entries(process.env) - .filter(([k, v]) => v !== undefined && k !== "PATH") + .filter(([k, v]) => + v !== undefined && + k !== "PATH" && + k !== "KANDELO_GUEST_OUTPUT_FILE" + ) .map(([k, v]) => `${k}=${v}`); return [...inherited, `PATH=${kernelPath}`]; } @@ -352,39 +352,53 @@ async function main() { stdinData = new Uint8Array(Buffer.concat(chunks)); } - const host = new NodeKernelHost({ - maxWorkers: 4, - onStdout: (_pid, data) => process.stdout.write(data), - onStderr: (_pid, data) => process.stderr.write(data), - onResolveExec: (path) => resolveProgram(path), - }); - - await host.init(); + // Conformance runners need guest fd 1 and fd 2 in one ordered stream while + // keeping host-runtime diagnostics out of expectation comparisons. The + // explicit file sink preserves callback order without changing the normal + // CLI behavior or hiding worker diagnostics from the outer process streams. + const guestOutputPath = process.env.KANDELO_GUEST_OUTPUT_FILE; + const guestOutputFd = guestOutputPath ? openSync(guestOutputPath, "w") : null; + const writeGuestOutput = (fallback: NodeJS.WriteStream, data: Uint8Array): void => { + if (guestOutputFd === null) { + fallback.write(data); + } else { + writeSync(guestOutputFd, data); + } + }; - const processArgv = [programPath, ...process.argv.slice(3)]; + let host: NodeKernelHost | undefined; + let status = 1; + try { + host = new NodeKernelHost({ + maxWorkers: 4, + onStdout: (_pid, data) => writeGuestOutput(process.stdout, data), + onStderr: (_pid, data) => writeGuestOutput(process.stderr, data), + onResolveExec: (path) => resolveProgram(path), + }); - const timeoutMs = parseInt(process.env.TIMEOUT || "30000", 10); - const exitPromise = host.spawn(loadBytes(programPath), processArgv, { - env: [ - ...guestEnv(), - ...gitEnv, - ], - cwd: process.env.KERNEL_CWD || process.cwd(), - stdin: stdinData, - }); + await host.init(); - const timeoutPromise = new Promise((_, reject) => { - setTimeout(() => reject(new Error("Process timed out")), timeoutMs); - }); + const processArgv = [programPath, ...process.argv.slice(3)]; + const timeoutMs = parseInt(process.env.TIMEOUT || "30000", 10); + const exitPromise = host.spawn(loadBytes(programPath), processArgv, { + env: [ + ...guestEnv(), + ...gitEnv, + ], + cwd: process.env.KERNEL_CWD || process.cwd(), + stdin: stdinData, + }); + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Process timed out")), timeoutMs); + }); - try { - const status = await Promise.race([exitPromise, timeoutPromise]); - await host.destroy().catch(() => {}); - process.exit(status); - } catch (e) { - await host.destroy().catch(() => {}); - throw e; + status = await Promise.race([exitPromise, timeoutPromise]); + } finally { + await host?.destroy().catch(() => {}); + if (guestOutputFd !== null) closeSync(guestOutputFd); } + + process.exit(status); } main().catch((e) => { 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/socket_timeout_options_test.c b/examples/socket_timeout_options_test.c new file mode 100644 index 0000000000..8ae9af329e --- /dev/null +++ b/examples/socket_timeout_options_test.c @@ -0,0 +1,71 @@ +#include +#include +#include +#include +#include +#include + +_Static_assert(sizeof(struct timeval) == 16, + "socket timeout marshalling requires a 16-byte timeval"); + +#if __SIZEOF_LONG__ == 4 +_Static_assert(SO_RCVTIMEO == 66, "wasm32 must use the time64 receive option"); +_Static_assert(SO_SNDTIMEO == 67, "wasm32 must use the time64 send option"); +#elif __SIZEOF_LONG__ == 8 +_Static_assert(SO_RCVTIMEO == 20, "wasm64 must use the long64 receive option"); +_Static_assert(SO_SNDTIMEO == 21, "wasm64 must use the long64 send option"); +#else +#error "unsupported long width" +#endif + +static int check(int condition, const char *message) +{ + if (condition) return 0; + fprintf(stderr, "socket timeout option failure: %s (errno=%d)\n", + message, errno); + return 1; +} + +static int set_and_check_timeout(int fd, int option, + const struct timeval *expected, const char *name) +{ + if (setsockopt(fd, SOL_SOCKET, option, expected, sizeof(*expected)) < 0) { + fprintf(stderr, "socket timeout option failure: set %s: %s\n", + name, strerror(errno)); + return 1; + } + + struct timeval actual = { .tv_sec = -1, .tv_usec = -1 }; + socklen_t length = sizeof(actual); + if (getsockopt(fd, SOL_SOCKET, option, &actual, &length) < 0) { + fprintf(stderr, "socket timeout option failure: get %s: %s\n", + name, strerror(errno)); + return 1; + } + + int failed = 0; + failed |= check(length == sizeof(actual), "get returns struct timeval size"); + failed |= check(actual.tv_sec == expected->tv_sec, name); + failed |= check(actual.tv_usec == expected->tv_usec, name); + return failed; +} + +int main(void) +{ + int fd = socket(AF_INET, SOCK_STREAM, 0); + if (fd < 0) { + perror("socket timeout option failure: socket"); + return 1; + } + + const struct timeval receive = { .tv_sec = 1, .tv_usec = 250000 }; + const struct timeval send = { .tv_sec = 2, .tv_usec = 500000 }; + int failed = 0; + failed |= set_and_check_timeout(fd, SO_RCVTIMEO, &receive, "receive timeout"); + failed |= set_and_check_timeout(fd, SO_SNDTIMEO, &send, "send timeout"); + failed |= check(close(fd) == 0, "close socket"); + + if (failed) return 1; + puts("SOCKET_TIMEOUT_OPTIONS_PASS"); + 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/examples/unix_listener_exec_test.c b/examples/unix_listener_exec_test.c new file mode 100644 index 0000000000..28049b81de --- /dev/null +++ b/examples/unix_listener_exec_test.c @@ -0,0 +1,298 @@ +#include +#include +#include +#include +#include +#include +#include +#include +#include + +static const char *const program_path = "/bin/unix-listener-exec"; +static const char *const socket_path = "/tmp/unix-listener-exec.sock"; + +static int parse_fd(const char *arg, const char *name) +{ + char *end = NULL; + errno = 0; + long parsed = strtol(arg, &end, 10); + if (errno != 0 || end == arg || *end != '\0' || parsed < 0 || + parsed > 1024) { + fprintf(stderr, "invalid %s fd: %s\n", name, arg); + return -1; + } + return (int)parsed; +} + +static int write_exact(int fd, const char *buf, size_t len) +{ + size_t offset = 0; + while (offset < len) { + ssize_t written = write(fd, buf + offset, len - offset); + if (written <= 0) + return -1; + offset += (size_t)written; + } + return 0; +} + +static int read_exact(int fd, char *buf, size_t len) +{ + size_t offset = 0; + while (offset < len) { + ssize_t received = read(fd, buf + offset, len - offset); + if (received <= 0) + return -1; + offset += (size_t)received; + } + return 0; +} + +static int worker_main(const char *listener_arg, const char *cloexec_arg, + const char *ready_arg) +{ + int listener = parse_fd(listener_arg, "inherited listener"); + int cloexec_listener = parse_fd(cloexec_arg, "CLOEXEC listener"); + int ready = parse_fd(ready_arg, "ready pipe"); + if (listener < 0 || cloexec_listener < 0 || ready < 0) + return 20; + + errno = 0; + if (fcntl(cloexec_listener, F_GETFD) != -1 || errno != EBADF) { + fprintf(stderr, "CLOEXEC listener survived exec\n"); + return 21; + } + + int accepting = 0; + socklen_t accepting_len = sizeof(accepting); + if (getsockopt(listener, SOL_SOCKET, SO_ACCEPTCONN, &accepting, + &accepting_len) != 0 || accepting != 1) { + perror("post-exec getsockopt(SO_ACCEPTCONN)"); + return 22; + } + + if (write_exact(ready, "R", 1) != 0) { + perror("post-exec ready write"); + return 23; + } + close(ready); + + static const char *const requests[] = { "PRE1", "POST" }; + static const char *const responses[] = { "ACK1", "ACK2" }; + for (size_t i = 0; i < 2; ++i) { + int peer = accept(listener, NULL, NULL); + if (peer < 0) { + perror("post-exec accept"); + return 24; + } + + char request[4]; + if (read_exact(peer, request, sizeof(request)) != 0 || + memcmp(request, requests[i], sizeof(request)) != 0) { + fprintf(stderr, + "post-exec accept %zu received the wrong request\n", i); + return 25; + } + if (write_exact(peer, responses[i], 4) != 0) { + perror("post-exec accepted socket write"); + return 26; + } + close(peer); + } + + int flags = fcntl(listener, F_GETFL); + if (flags < 0 || fcntl(listener, F_SETFL, flags | O_NONBLOCK) != 0) { + perror("post-exec listener nonblock"); + return 27; + } + errno = 0; + int extra = accept(listener, NULL, NULL); + if (extra != -1 || (errno != EAGAIN && errno != EWOULDBLOCK)) { + fprintf(stderr, "listener queue was not consumed exactly once\n"); + return 28; + } + + close(listener); + return 0; +} + +static int make_listener(void) +{ + int fd = socket(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC, 0); + if (fd < 0) { + perror("socket listener"); + return -1; + } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + if (strlen(socket_path) >= sizeof(addr.sun_path)) { + fprintf(stderr, "socket path is too long\n"); + close(fd); + return -1; + } + strcpy(addr.sun_path, socket_path); + + unlink(socket_path); + if (bind(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + perror("bind listener"); + close(fd); + return -1; + } + if (listen(fd, 4) != 0) { + perror("listen"); + close(fd); + return -1; + } + return fd; +} + +static int connect_client(void) +{ + int fd = socket(AF_UNIX, SOCK_STREAM, 0); + if (fd < 0) { + perror("socket client"); + return -1; + } + + struct sockaddr_un addr; + memset(&addr, 0, sizeof(addr)); + addr.sun_family = AF_UNIX; + strcpy(addr.sun_path, socket_path); + if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) { + perror("connect"); + close(fd); + return -1; + } + return fd; +} + +int main(int argc, char **argv) +{ + alarm(20); + if (argc == 5 && strcmp(argv[1], "worker") == 0) + return worker_main(argv[2], argv[3], argv[4]); + + int cloexec_listener = make_listener(); + if (cloexec_listener < 0) + return 1; + int listener = dup(cloexec_listener); + if (listener < 0) { + perror("dup listener"); + return 2; + } + int cloexec_flags = fcntl(cloexec_listener, F_GETFD); + int listener_flags = fcntl(listener, F_GETFD); + if (cloexec_flags < 0 || listener_flags < 0) { + perror("listener F_GETFD"); + return 2; + } + if ((cloexec_flags & FD_CLOEXEC) == 0 || + (listener_flags & FD_CLOEXEC) != 0) { + fprintf(stderr, "dup did not preserve the expected CLOEXEC split\n"); + return 2; + } + + int exec_gate[2]; + int ready[2]; + if (pipe(exec_gate) != 0 || pipe(ready) != 0) { + perror("coordination pipe"); + return 3; + } + + pid_t child = fork(); + if (child < 0) { + perror("fork"); + return 4; + } + if (child == 0) { + close(exec_gate[1]); + close(ready[0]); + char byte; + if (read_exact(exec_gate[0], &byte, 1) != 0) { + perror("child exec gate read"); + _exit(10); + } + close(exec_gate[0]); + + char listener_arg[16]; + char cloexec_arg[16]; + char ready_arg[16]; + snprintf(listener_arg, sizeof(listener_arg), "%d", listener); + snprintf(cloexec_arg, sizeof(cloexec_arg), "%d", cloexec_listener); + snprintf(ready_arg, sizeof(ready_arg), "%d", ready[1]); + char *exec_argv[] = { + (char *)program_path, + "worker", + listener_arg, + cloexec_arg, + ready_arg, + NULL, + }; + char *exec_envp[] = { NULL }; + execve(program_path, exec_argv, exec_envp); + perror("execve worker"); + _exit(11); + } + + close(exec_gate[0]); + close(ready[1]); + int pre_exec_client = connect_client(); + if (pre_exec_client < 0) + return 5; + + // Drop every listener alias in the parent. The child is now the only + // process that can keep the pathname registration and shared queue alive. + close(cloexec_listener); + close(listener); + + // The connection is queued while the child still runs the pre-exec + // image. Releasing the gate makes the child replace that image and prove + // that its inherited listener still owns this exact pending connection. + if (write_exact(exec_gate[1], "x", 1) != 0) { + perror("parent gate write"); + return 6; + } + close(exec_gate[1]); + + char byte; + if (read_exact(ready[0], &byte, 1) != 0) { + perror("parent ready read"); + return 7; + } + close(ready[0]); + + int post_exec_client = connect_client(); + if (post_exec_client < 0) + return 8; + + if (write_exact(pre_exec_client, "PRE1", 4) != 0 || + write_exact(post_exec_client, "POST", 4) != 0) { + perror("client write"); + return 9; + } + char first_response[4]; + char second_response[4]; + if (read_exact(pre_exec_client, first_response, sizeof(first_response)) != 0 || + memcmp(first_response, "ACK1", sizeof(first_response)) != 0 || + read_exact(post_exec_client, second_response, sizeof(second_response)) != 0 || + memcmp(second_response, "ACK2", sizeof(second_response)) != 0) { + fprintf(stderr, "clients received the wrong responses\n"); + return 10; + } + close(pre_exec_client); + close(post_exec_client); + + int status = 0; + if (waitpid(child, &status, 0) != child || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) { + fprintf(stderr, "exec worker failed: status=%d\n", status); + return 11; + } + + unlink(socket_path); + alarm(0); + puts("UNIX_LISTENER_EXEC_PASS"); + return 0; +} diff --git a/examples/wait_lifecycle_test.c b/examples/wait_lifecycle_test.c new file mode 100644 index 0000000000..ba66130047 --- /dev/null +++ b/examples/wait_lifecycle_test.c @@ -0,0 +1,1231 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern char **environ; +extern long __syscall_cp_check(long result); + +static int fail(const char *step) +{ + fprintf(stderr, "%s: errno=%d (%s)\n", step, errno, strerror(errno)); + return -1; +} + +static volatile sig_atomic_t sigchld_count; +static volatile sig_atomic_t sigusr1_count; +static volatile sig_atomic_t sigcont_count; +static volatile sig_atomic_t siginfo_signo; +static volatile sig_atomic_t siginfo_pid; +static volatile sig_atomic_t siginfo_uid; +static volatile sig_atomic_t cancel_enable_error; + +static void count_sigchld(int signum) +{ + (void)signum; + sigchld_count++; +} + +static void count_sigusr1(int signum) +{ + (void)signum; + sigusr1_count++; +} + +static void count_sigcont(int signum) +{ + (void)signum; + sigcont_count++; +} + +static void capture_siginfo(int signum, siginfo_t *info, void *ucontext) +{ + (void)ucontext; + siginfo_signo = signum; + siginfo_pid = info->si_pid; + siginfo_uid = info->si_uid; +} + +static void enable_pending_cancel(int signum) +{ + (void)signum; + int error = pthread_setcancelstate(PTHREAD_CANCEL_ENABLE, NULL); + if (error != 0) + cancel_enable_error = error; +} + +struct completed_cancel_ctx { + atomic_int ready; + atomic_int proceed; + atomic_int preserved_success; + atomic_int returned_from_eintr; + atomic_int cleanup_ran; +}; + +static void record_completed_cancel_cleanup(void *opaque) +{ + struct completed_cancel_ctx *ctx = opaque; + atomic_store_explicit(&ctx->cleanup_ran, 1, memory_order_release); +} + +static void *completed_cancel_thread(void *opaque) +{ + struct completed_cancel_ctx *ctx = opaque; + + pthread_cleanup_push(record_completed_cancel_cleanup, ctx); + atomic_store_explicit(&ctx->ready, 1, memory_order_release); + while (!atomic_load_explicit(&ctx->proceed, memory_order_acquire)) { + /* Stay outside cancellation points until the caller sets cancel. */ + } + + /* This models the post-dispatch edge of a cancellation-point syscall. + * Once the syscall completed successfully, cancellation must remain + * pending instead of replacing a result whose side effects are visible. */ + if (__syscall_cp_check(17) == 17) { + atomic_store_explicit( + &ctx->preserved_success, + 1, + memory_order_release + ); + } + + /* Host-interrupted cancellation points complete with EINTR. That is the + * post-dispatch edge where deferred cancellation must take effect. */ + (void)__syscall_cp_check(-EINTR); + atomic_store_explicit( + &ctx->returned_from_eintr, + 1, + memory_order_release + ); + pthread_cleanup_pop(0); + return NULL; +} + +static int test_cancel_preserves_completed_syscall(void) +{ + struct completed_cancel_ctx ctx = { + .ready = ATOMIC_VAR_INIT(0), + .proceed = ATOMIC_VAR_INIT(0), + .preserved_success = ATOMIC_VAR_INIT(0), + .returned_from_eintr = ATOMIC_VAR_INIT(0), + .cleanup_ran = ATOMIC_VAR_INIT(0), + }; + pthread_t thread; + int error = pthread_create(&thread, NULL, completed_cancel_thread, &ctx); + if (error != 0) { + errno = error; + return fail("completed-cancel pthread_create"); + } + + while (!atomic_load_explicit(&ctx.ready, memory_order_acquire)) + usleep(1000); + error = pthread_cancel(thread); + if (error != 0) { + errno = error; + return fail("completed-cancel pthread_cancel"); + } + atomic_store_explicit(&ctx.proceed, 1, memory_order_release); + + void *joined = NULL; + error = pthread_join(thread, &joined); + if (error != 0) { + errno = error; + return fail("completed-cancel pthread_join"); + } + if (joined != PTHREAD_CANCELED || + !atomic_load_explicit(&ctx.preserved_success, memory_order_acquire) || + atomic_load_explicit(&ctx.returned_from_eintr, memory_order_acquire) || + !atomic_load_explicit(&ctx.cleanup_ran, memory_order_acquire)) { + fprintf(stderr, + "completed syscall cancellation mismatch: joined=%p " + "preserved=%d returned_eintr=%d cleanup=%d\n", + joined, + atomic_load_explicit( + &ctx.preserved_success, + memory_order_relaxed + ), + atomic_load_explicit( + &ctx.returned_from_eintr, + memory_order_relaxed + ), + atomic_load_explicit(&ctx.cleanup_ran, memory_order_relaxed)); + return -1; + } + return 0; +} + +static int read_all(const char *path, char *buf, size_t size) +{ + FILE *fp = fopen(path, "r"); + if (fp == NULL) + return fail(path); + size_t used = fread(buf, 1, size - 1, fp); + if (ferror(fp)) { + fclose(fp); + return fail(path); + } + buf[used] = '\0'; + fclose(fp); + return 0; +} + +static int expect_proc_stopped(pid_t pid) +{ + char path[64]; + char buf[1024]; + + snprintf(path, sizeof(path), "/proc/%d/stat", (int)pid); + if (read_all(path, buf, sizeof(buf)) != 0) + return -1; + char *end_name = strrchr(buf, ')'); + if (end_name == NULL || end_name[1] != ' ' || end_name[2] != 'T') { + fprintf(stderr, "proc stat did not report stopped state: %s\n", buf); + return -1; + } + + snprintf(path, sizeof(path), "/proc/%d/status", (int)pid); + if (read_all(path, buf, sizeof(buf)) != 0) + return -1; + if (strstr(buf, "State:\tT (stopped)") == NULL) { + fprintf(stderr, "proc status did not report stopped state: %s\n", buf); + return -1; + } + return 0; +} + +static int expect_zero_rusage(const struct rusage *usage) +{ + const unsigned char *bytes = (const unsigned char *)usage; + const size_t meaningful = sizeof(long) == 4 ? 88 : 144; + + for (size_t i = 0; i < meaningful; i++) { + if (bytes[i] != 0) { + fprintf(stderr, "rusage byte %zu was %u, expected zero\n", + i, (unsigned)bytes[i]); + return -1; + } + } + for (size_t i = meaningful; i < sizeof(*usage); i++) { + if (bytes[i] != 0xa5) { + fprintf(stderr, "rusage reserved byte %zu was overwritten\n", i); + return -1; + } + } + return 0; +} + +static pid_t spawn_stopping_child(int gate[2], int exit_code) +{ + if (pipe(gate) != 0) { + fail("pipe"); + return -1; + } + pid_t pid = fork(); + if (pid < 0) { + fail("fork"); + return -1; + } + if (pid == 0) { + close(gate[1]); + struct sigaction cont; + memset(&cont, 0, sizeof(cont)); + cont.sa_handler = SIG_IGN; + sigemptyset(&cont.sa_mask); + if (sigaction(SIGCONT, &cont, NULL) != 0) + _exit(119); + if (raise(SIGSTOP) != 0) + _exit(120); + char byte = 0; + if (read(gate[0], &byte, 1) != 1) + _exit(121); + close(gate[0]); + _exit(exit_code); + } + close(gate[0]); + return pid; +} + +static int release_child(int fd) +{ + if (write(fd, "x", 1) != 1) + return fail("release child"); + return close(fd) == 0 ? 0 : fail("close child gate"); +} + +static int test_wait4_lifecycle(void) +{ + int gate[2]; + pid_t pid = spawn_stopping_child(gate, 42); + if (pid < 0) + return -1; + + int status = 0x12345678; + struct rusage usage; + errno = 0; + if (syscall(SYS_wait4, pid, (int *)(uintptr_t)-1, WUNTRACED, NULL) != -1 || + errno != EFAULT) { + fprintf(stderr, "wait4 invalid status did not return EFAULT: errno=%d\n", + errno); + return -1; + } + errno = 0; + if (syscall(SYS_wait4, pid, &status, WUNTRACED, + (struct rusage *)(uintptr_t)-1) != -1 || errno != EFAULT || + status != 0x12345678) { + fprintf(stderr, "wait4 invalid rusage was not transactional: errno=%d\n", + errno); + return -1; + } + memset(&usage, 0xa5, sizeof(usage)); + pid_t got = wait4(pid, &status, WUNTRACED, &usage); + if (got != pid || !WIFSTOPPED(status) || WSTOPSIG(status) != SIGSTOP) { + fprintf(stderr, "wait4 stop mismatch: got=%d status=%#x\n", + (int)got, status); + return -1; + } + if (expect_zero_rusage(&usage) != 0 || expect_proc_stopped(pid) != 0) + return -1; + + status = 0x13572468; + memset(&usage, 0x5a, sizeof(usage)); + struct rusage unchanged = usage; + got = wait4(pid, &status, WUNTRACED | WNOHANG, &usage); + if (got != 0 || status != 0x13572468 || + memcmp(&usage, &unchanged, sizeof(usage)) != 0) { + fprintf(stderr, "wait4 no-event call modified caller outputs\n"); + return -1; + } + + if (kill(pid, SIGCONT) != 0) + return fail("SIGCONT"); + memset(&usage, 0xa5, sizeof(usage)); + got = wait4(pid, &status, WCONTINUED, &usage); + if (got != pid || !WIFCONTINUED(status) || expect_zero_rusage(&usage) != 0) { + fprintf(stderr, "wait4 continue mismatch: got=%d status=%#x\n", + (int)got, status); + return -1; + } + + if (release_child(gate[1]) != 0) + return -1; + memset(&usage, 0xa5, sizeof(usage)); + got = wait4(pid, &status, 0, &usage); + if (got != pid || !WIFEXITED(status) || WEXITSTATUS(status) != 42 || + expect_zero_rusage(&usage) != 0) { + fprintf(stderr, "wait4 exit mismatch: got=%d status=%#x\n", + (int)got, status); + return -1; + } + + errno = 0; + if (waitpid(pid, &status, WNOHANG | WUNTRACED) != -1 || errno != ECHILD) { + fprintf(stderr, "post-reap wait did not return ECHILD: errno=%d\n", errno); + return -1; + } + return 0; +} + +static int expect_waitid_event( + pid_t pid, + int options, + int expected_code, + int expected_status, + int nowait) +{ + siginfo_t info; + memset(&info, 0x5a, sizeof(info)); + int rc = waitid(P_PID, (id_t)pid, &info, options | (nowait ? WNOWAIT : 0)); + if (rc != 0 || info.si_signo != SIGCHLD || info.si_pid != pid || + info.si_code != expected_code || info.si_status != expected_status || + info.si_uid != getuid()) { + fprintf(stderr, + "waitid mismatch: rc=%d signo=%d pid=%d uid=%u code=%d status=%d\n", + rc, info.si_signo, (int)info.si_pid, (unsigned)info.si_uid, + info.si_code, info.si_status); + return -1; + } + return 0; +} + +static int test_waitid_lifecycle(void) +{ + int gate[2]; + pid_t pid = spawn_stopping_child(gate, 7); + if (pid < 0) + return -1; + + errno = 0; + if (syscall(SYS_waitid, P_PID, (id_t)pid, + (siginfo_t *)(uintptr_t)-1, WSTOPPED, NULL) != -1 || + errno != EFAULT) { + fprintf(stderr, "waitid invalid siginfo did not return EFAULT: errno=%d\n", + errno); + return -1; + } + if (expect_waitid_event(pid, WSTOPPED, CLD_STOPPED, SIGSTOP, 1) != 0 || + expect_waitid_event(pid, WSTOPPED, CLD_STOPPED, SIGSTOP, 1) != 0 || + expect_waitid_event(pid, WSTOPPED, CLD_STOPPED, SIGSTOP, 0) != 0) + return -1; + + siginfo_t info; + memset(&info, 0x5a, sizeof(info)); + if (waitid(P_PID, (id_t)pid, &info, WSTOPPED | WNOHANG) != 0) { + return fail("waitid WNOHANG"); + } + const unsigned char *bytes = (const unsigned char *)&info; + for (size_t i = 0; i < sizeof(info); i++) { + if (bytes[i] != 0) { + fprintf(stderr, "waitid WNOHANG left byte %zu nonzero\n", i); + return -1; + } + } + + if (kill(pid, SIGCONT) != 0) + return fail("waitid SIGCONT"); + if (expect_waitid_event(pid, WCONTINUED, CLD_CONTINUED, SIGCONT, 0) != 0) + return -1; + if (release_child(gate[1]) != 0) + return -1; + if (expect_waitid_event(pid, WEXITED, CLD_EXITED, 7, 0) != 0) + return -1; + return 0; +} + +static int test_sigkill_stopped_child(void) +{ + int gate[2]; + pid_t pid = spawn_stopping_child(gate, 0); + if (pid < 0) + return -1; + + int status = 0; + if (waitpid(pid, &status, WUNTRACED) != pid || !WIFSTOPPED(status)) { + fprintf(stderr, "SIGKILL child did not stop first: status=%#x\n", status); + return -1; + } + if (kill(pid, SIGKILL) != 0) + return fail("SIGKILL"); + if (waitpid(pid, &status, 0) != pid || !WIFSIGNALED(status) || + WTERMSIG(status) != SIGKILL) { + fprintf(stderr, "SIGKILL child status mismatch: status=%#x\n", status); + return -1; + } + close(gate[1]); + return 0; +} + +static int test_sa_nocldstop(void) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = count_sigchld; + action.sa_flags = SA_NOCLDSTOP; + sigemptyset(&action.sa_mask); + if (sigaction(SIGCHLD, &action, NULL) != 0) + return fail("sigaction SA_NOCLDSTOP"); + sigchld_count = 0; + + int gate[2]; + pid_t pid = spawn_stopping_child(gate, 9); + if (pid < 0) + return -1; + int status = 0; + if (waitpid(pid, &status, WUNTRACED) != pid || !WIFSTOPPED(status) || + sigchld_count != 0) { + fprintf(stderr, "SA_NOCLDSTOP stop notification mismatch: count=%d\n", + (int)sigchld_count); + return -1; + } + if (kill(pid, SIGCONT) != 0) + return fail("SA_NOCLDSTOP SIGCONT"); + if (waitpid(pid, &status, WCONTINUED) != pid || !WIFCONTINUED(status) || + sigchld_count != 0) { + fprintf(stderr, "SA_NOCLDSTOP continue notification mismatch: count=%d\n", + (int)sigchld_count); + return -1; + } + if (release_child(gate[1]) != 0) + return -1; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != 9 || sigchld_count != 1) { + fprintf(stderr, "SA_NOCLDSTOP exit notification mismatch: status=%#x count=%d\n", + status, (int)sigchld_count); + return -1; + } + + action.sa_handler = SIG_DFL; + action.sa_flags = 0; + if (sigaction(SIGCHLD, &action, NULL) != 0) + return fail("restore SIGCHLD"); + return 0; +} + +static int test_getrusage_pointer_validation(void) +{ + errno = 0; + if (syscall(SYS_getrusage, RUSAGE_SELF, NULL) != -1 || errno != EFAULT) { + fprintf(stderr, "getrusage NULL did not return EFAULT: errno=%d\n", errno); + return -1; + } + errno = 0; + if (syscall(SYS_getrusage, RUSAGE_SELF, (void *)(uintptr_t)-1) != -1 || + errno != EFAULT) { + fprintf(stderr, "getrusage invalid range did not return EFAULT: errno=%d\n", + errno); + return -1; + } + + struct rusage usage; + memset(&usage, 0xa5, sizeof(usage)); + if (getrusage(RUSAGE_SELF, &usage) != 0) + return fail("getrusage valid"); + return expect_zero_rusage(&usage); +} + +static int test_nonmatching_sigchld_interrupts_wait(void) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = count_sigchld; + sigemptyset(&action.sa_mask); + if (sigaction(SIGCHLD, &action, NULL) != 0) + return fail("sigaction interrupting SIGCHLD"); + sigchld_count = 0; + + int gate[2]; + pid_t pid = spawn_stopping_child(gate, 27); + if (pid < 0) + return -1; + + int status = 0; + errno = 0; + if (waitpid(pid, &status, 0) != -1 || errno != EINTR || sigchld_count != 1) { + fprintf(stderr, + "nonmatching stop SIGCHLD did not interrupt wait: errno=%d count=%d\n", + errno, (int)sigchld_count); + return -1; + } + + if (kill(pid, SIGCONT) != 0) + return fail("interrupt test SIGCONT"); + if (waitpid(pid, &status, WCONTINUED) != pid || !WIFCONTINUED(status)) { + fprintf(stderr, "interrupt test continue mismatch: status=%#x\n", status); + return -1; + } + if (release_child(gate[1]) != 0) + return -1; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != 27) { + fprintf(stderr, "interrupt test exit mismatch: status=%#x\n", status); + return -1; + } + + action.sa_handler = SIG_DFL; + if (sigaction(SIGCHLD, &action, NULL) != 0) + return fail("restore interrupting SIGCHLD"); + return 0; +} + +static int run_wait_restart_case(int restart, int exit_code) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = count_sigusr1; + action.sa_flags = restart ? SA_RESTART : 0; + sigemptyset(&action.sa_mask); + if (sigaction(SIGUSR1, &action, NULL) != 0) + return fail("sigaction SIGUSR1"); + sigusr1_count = 0; + + int gate[2]; + if (pipe(gate) != 0) + return fail("restart pipe"); + pid_t pid = fork(); + if (pid < 0) + return fail("restart fork"); + if (pid == 0) { + close(gate[1]); + char byte; + if (read(gate[0], &byte, 1) != 1) + _exit(121); + close(gate[0]); + usleep(20000); + if (kill(getppid(), SIGUSR1) != 0) + _exit(122); + usleep(20000); + _exit(exit_code); + } + close(gate[0]); + if (release_child(gate[1]) != 0) + return -1; + + int status = 0; + errno = 0; + pid_t got = waitpid(pid, &status, 0); + if (restart) { + if (got != pid || !WIFEXITED(status) || WEXITSTATUS(status) != exit_code || + sigusr1_count != 1) { + fprintf(stderr, + "SA_RESTART wait mismatch: got=%d status=%#x errno=%d count=%d\n", + (int)got, status, errno, (int)sigusr1_count); + return -1; + } + } else { + if (got != -1 || errno != EINTR || sigusr1_count != 1) { + fprintf(stderr, + "interruptible wait mismatch: got=%d errno=%d count=%d\n", + (int)got, errno, (int)sigusr1_count); + return -1; + } + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != exit_code) { + fprintf(stderr, "post-EINTR wait mismatch: status=%#x\n", status); + return -1; + } + } + + action.sa_handler = SIG_DFL; + action.sa_flags = 0; + if (sigaction(SIGUSR1, &action, NULL) != 0) + return fail("restore SIGUSR1"); + return 0; +} + +static int test_wait_signal_restart(void) +{ + if (run_wait_restart_case(0, 28) != 0) + return -1; + return run_wait_restart_case(1, 29); +} + +struct directed_wait_ctx { + pid_t child; + atomic_int ready; + atomic_int done; + pid_t result; + int error; +}; + +static void *directed_wait_thread(void *opaque) +{ + struct directed_wait_ctx *ctx = opaque; + int status = 0; + atomic_store_explicit(&ctx->ready, 1, memory_order_release); + errno = 0; + ctx->result = waitpid(ctx->child, &status, 0); + ctx->error = errno; + atomic_store_explicit(&ctx->done, 1, memory_order_release); + return NULL; +} + +static int test_pthread_kill_interrupts_exact_waiter(void) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = count_sigusr1; + sigemptyset(&action.sa_mask); + if (sigaction(SIGUSR1, &action, NULL) != 0) + return fail("directed sigaction"); + sigusr1_count = 0; + + int gate[2]; + if (pipe(gate) != 0) + return fail("directed pipe"); + pid_t pid = fork(); + if (pid < 0) + return fail("directed fork"); + if (pid == 0) { + close(gate[1]); + char byte; + if (read(gate[0], &byte, 1) != 1) + _exit(121); + close(gate[0]); + _exit(30); + } + close(gate[0]); + + struct directed_wait_ctx ctx = { + .child = pid, + .result = 0, + .error = 0, + }; + atomic_init(&ctx.ready, 0); + atomic_init(&ctx.done, 0); + pthread_t thread; + if (pthread_create(&thread, NULL, directed_wait_thread, &ctx) != 0) + return fail("pthread_create directed waiter"); + while (!atomic_load_explicit(&ctx.ready, memory_order_acquire)) + usleep(1000); + + int attempts = 0; + while (!atomic_load_explicit(&ctx.done, memory_order_acquire) && + attempts++ < 5000) { + int kill_error = pthread_kill(thread, SIGUSR1); + if (kill_error != 0) { + if (atomic_load_explicit(&ctx.done, memory_order_acquire)) + break; + errno = kill_error; + return fail("pthread_kill directed waiter"); + } + usleep(1000); + } + if (!atomic_load_explicit(&ctx.done, memory_order_acquire)) { + /* Let a broken waiter finish so the failure path cannot leak a child. */ + if (release_child(gate[1]) != 0) + return -1; + pthread_join(thread, NULL); + fprintf(stderr, "directed wait did not react to a thread signal\n"); + return -1; + } + if (pthread_join(thread, NULL) != 0) + return fail("pthread_join directed waiter"); + if (ctx.result != -1 || ctx.error != EINTR || sigusr1_count < 1) { + fprintf(stderr, + "directed wait was not interrupted: result=%d errno=%d count=%d\n", + (int)ctx.result, ctx.error, (int)sigusr1_count); + return -1; + } + + if (release_child(gate[1]) != 0) + return -1; + int status = 0; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != 30) { + fprintf(stderr, "directed child exit mismatch: status=%#x\n", status); + return -1; + } + + action.sa_handler = SIG_DFL; + if (sigaction(SIGUSR1, &action, NULL) != 0) + return fail("restore directed SIGUSR1"); + return 0; +} + +struct restart_cancel_ctx { + pid_t child; + atomic_int ready; + atomic_int cleanup_ran; + pid_t result; + int error; +}; + +static void record_restart_cancel(void *opaque) +{ + atomic_store_explicit((atomic_int *)opaque, 1, memory_order_release); +} + +static void *restart_cancel_wait_thread(void *opaque) +{ + struct restart_cancel_ctx *ctx = opaque; + int old_state; + int status = 0; + + if (pthread_setcancelstate(PTHREAD_CANCEL_DISABLE, &old_state) != 0) + return (void *)(uintptr_t)1; + if (pthread_cancel(pthread_self()) != 0) + return (void *)(uintptr_t)2; + + atomic_store_explicit(&ctx->ready, 1, memory_order_release); + pthread_cleanup_push(record_restart_cancel, &ctx->cleanup_ran); + errno = 0; + ctx->result = waitpid(ctx->child, &status, 0); + ctx->error = errno; + pthread_cleanup_pop(0); + return NULL; +} + +static int test_sa_restart_checks_pending_cancel_before_retry(void) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = enable_pending_cancel; + action.sa_flags = SA_RESTART; + sigemptyset(&action.sa_mask); + if (sigaction(SIGUSR2, &action, NULL) != 0) + return fail("restart-cancel sigaction"); + cancel_enable_error = 0; + + int gate[2]; + if (pipe(gate) != 0) + return fail("restart-cancel pipe"); + pid_t pid = fork(); + if (pid < 0) + return fail("restart-cancel fork"); + if (pid == 0) { + close(gate[1]); + char byte; + if (read(gate[0], &byte, 1) != 1) + _exit(121); + close(gate[0]); + _exit(31); + } + close(gate[0]); + + struct restart_cancel_ctx ctx = { + .child = pid, + .ready = ATOMIC_VAR_INIT(0), + .cleanup_ran = ATOMIC_VAR_INIT(0), + .result = 0, + .error = 0, + }; + pthread_t thread; + int create_error = pthread_create( + &thread, + NULL, + restart_cancel_wait_thread, + &ctx + ); + if (create_error != 0) { + errno = create_error; + return fail("restart-cancel pthread_create"); + } + while (!atomic_load_explicit(&ctx.ready, memory_order_acquire)) + usleep(1000); + usleep(20000); + + int kill_error = pthread_kill(thread, SIGUSR2); + if (kill_error != 0) { + errno = kill_error; + return fail("restart-cancel pthread_kill"); + } + + int canceled_before_child_exit = 0; + for (int i = 0; i < 250; i++) { + if (atomic_load_explicit(&ctx.cleanup_ran, memory_order_acquire)) { + canceled_before_child_exit = 1; + break; + } + usleep(1000); + } + + if (release_child(gate[1]) != 0) + return -1; + void *joined = NULL; + if (pthread_join(thread, &joined) != 0) + return fail("restart-cancel pthread_join"); + + int status = 0; + if (canceled_before_child_exit) { + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != 31) { + fprintf(stderr, "restart-cancel child status mismatch: %#x\n", status); + return -1; + } + } + + action.sa_handler = SIG_DFL; + action.sa_flags = 0; + if (sigaction(SIGUSR2, &action, NULL) != 0) + return fail("restore restart-cancel SIGUSR2"); + + if (!canceled_before_child_exit || joined != PTHREAD_CANCELED || + cancel_enable_error != 0) { + fprintf(stderr, + "SA_RESTART swallowed cancellation: early=%d joined=%p " + "handler_error=%d wait_result=%d wait_errno=%d\n", + canceled_before_child_exit, joined, (int)cancel_enable_error, + (int)ctx.result, ctx.error); + return -1; + } + return 0; +} + +static int test_siginfo_layout(void) +{ + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_sigaction = capture_siginfo; + action.sa_flags = SA_SIGINFO; + sigemptyset(&action.sa_mask); + if (sigaction(SIGUSR2, &action, NULL) != 0) + return fail("siginfo sigaction"); + siginfo_signo = 0; + siginfo_pid = 0; + siginfo_uid = -1; + + if (raise(SIGUSR2) != 0) + return fail("siginfo raise"); + if (siginfo_signo != SIGUSR2 || siginfo_pid != getpid() || + siginfo_uid != (sig_atomic_t)getuid()) { + fprintf(stderr, + "siginfo layout mismatch: signo=%d pid=%d uid=%d expected pid=%d uid=%u\n", + (int)siginfo_signo, (int)siginfo_pid, (int)siginfo_uid, + (int)getpid(), (unsigned)getuid()); + return -1; + } + + action.sa_handler = SIG_DFL; + action.sa_flags = 0; + if (sigaction(SIGUSR2, &action, NULL) != 0) + return fail("restore siginfo SIGUSR2"); + return 0; +} + +static int test_caught_sigcont_precedes_stop_boundary_return(void) +{ + pid_t pid = fork(); + if (pid < 0) + return fail("SIGCONT ordering fork"); + if (pid == 0) { + struct sigaction action; + memset(&action, 0, sizeof(action)); + action.sa_handler = count_sigcont; + sigemptyset(&action.sa_mask); + if (sigaction(SIGCONT, &action, NULL) != 0) + _exit(121); + sigcont_count = 0; + if (raise(SIGSTOP) != 0) + _exit(122); + _exit(sigcont_count == 1 ? 0 : 123); + } + + int status = 0; + if (waitpid(pid, &status, WUNTRACED) != pid || !WIFSTOPPED(status)) { + fprintf(stderr, "SIGCONT ordering child did not stop: status=%#x\n", status); + return -1; + } + if (kill(pid, SIGCONT) != 0) + return fail("SIGCONT ordering continue"); + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status) || + WEXITSTATUS(status) != 0) { + fprintf(stderr, + "caught SIGCONT did not run before resume boundary: status=%#x\n", + status); + return -1; + } + return 0; +} + +#if __SIZEOF_POINTER__ == 8 +struct memory64_k_sigaction { + uint64_t handler; + uint64_t flags; + uint64_t mask; + uint64_t unused; +}; + +static int memory64_action_is_configured(void) +{ + struct sigaction current; + memset(¤t, 0, sizeof(current)); + if (sigaction(SIGUSR2, NULL, ¤t) != 0) + return fail("memory64 query sigaction"); + if (current.sa_sigaction != capture_siginfo || + (current.sa_flags & (SA_SIGINFO | SA_RESTART)) != + (SA_SIGINFO | SA_RESTART) || + sigismember(¤t.sa_mask, SIGUSR1) != 1) { + fprintf(stderr, + "memory64 sigaction round-trip mismatch: handler=%p flags=%#x " + "mask=%d\n", + (void *)current.sa_sigaction, current.sa_flags, + sigismember(¤t.sa_mask, SIGUSR1)); + return -1; + } + return 0; +} + +static int test_memory64_sigaction_wire(void) +{ + struct sigaction original; + struct sigaction configured; + struct sigaction previous; + memset(&original, 0, sizeof(original)); + memset(&configured, 0, sizeof(configured)); + memset(&previous, 0, sizeof(previous)); + if (sigaction(SIGUSR2, NULL, &original) != 0) + return fail("memory64 save sigaction"); + + configured.sa_sigaction = capture_siginfo; + configured.sa_flags = SA_SIGINFO | SA_RESTART; + sigemptyset(&configured.sa_mask); + sigaddset(&configured.sa_mask, SIGUSR1); + if (sigaction(SIGUSR2, &configured, &previous) != 0) + return fail("memory64 set sigaction"); + if (previous.sa_handler != original.sa_handler || + previous.sa_flags != original.sa_flags || + memcmp(&previous.sa_mask, &original.sa_mask, + sizeof(previous.sa_mask)) != 0) { + fprintf(stderr, "memory64 old sigaction did not round-trip\n"); + return -1; + } + if (memory64_action_is_configured() != 0) + return -1; + + errno = 0; + if (syscall(SYS_rt_sigaction, SIGUSR2, (void *)(uintptr_t)-1, + NULL, 8) != -1 || errno != EFAULT || + memory64_action_is_configured() != 0) { + fprintf(stderr, "memory64 invalid sigaction input mismatch: errno=%d\n", + errno); + return -1; + } + errno = 0; + if (syscall(SYS_rt_sigaction, SIGUSR2, NULL, + (void *)(uintptr_t)-1, 8) != -1 || errno != EFAULT || + memory64_action_is_configured() != 0) { + fprintf(stderr, "memory64 invalid sigaction output mismatch: errno=%d\n", + errno); + return -1; + } + + uintptr_t old_pages = (uintptr_t)__builtin_wasm_memory_grow(0, 1); + if (old_pages == (uintptr_t)-1) { + fprintf(stderr, "memory64 could not grow memory for range test\n"); + return -1; + } + unsigned char *tail = + (unsigned char *)((old_pages + 1) * 65536u - 23u); + memset(tail, 0xa5, 23); + errno = 0; + if (syscall(SYS_rt_sigaction, SIGUSR2, tail, NULL, 8) != -1 || + errno != EFAULT || memory64_action_is_configured() != 0) { + fprintf(stderr, "memory64 short sigaction input mismatch: errno=%d\n", + errno); + return -1; + } + errno = 0; + if (syscall(SYS_rt_sigaction, SIGUSR2, NULL, tail, 8) != -1 || + errno != EFAULT || memory64_action_is_configured() != 0) { + fprintf(stderr, "memory64 short sigaction output mismatch: errno=%d\n", + errno); + return -1; + } + for (size_t i = 0; i < 23; i++) { + if (tail[i] != 0xa5) { + fprintf(stderr, "memory64 invalid sigaction output mutated byte %zu\n", + i); + return -1; + } + } + + struct memory64_k_sigaction raw; + struct memory64_k_sigaction unchanged; + memset(&raw, 0, sizeof(raw)); + memset(&unchanged, 0xa5, sizeof(unchanged)); + raw.handler = UINT64_C(1) << 32; + errno = 0; + if (syscall(SYS_rt_sigaction, SIGUSR2, &raw, &unchanged, 8) != -1 || + errno != EINVAL || memory64_action_is_configured() != 0) { + fprintf(stderr, "memory64 wide sigaction handler mismatch: errno=%d\n", + errno); + return -1; + } + for (size_t i = 0; i < sizeof(unchanged); i++) { + if (((unsigned char *)&unchanged)[i] != 0xa5) { + fprintf(stderr, "memory64 rejected sigaction mutated old action\n"); + return -1; + } + } + + raw.handler = (uint64_t)(uintptr_t)capture_siginfo; + raw.flags = UINT64_C(1) << 32; + errno = 0; + if (syscall(SYS_rt_sigaction, SIGUSR2, &raw, NULL, 8) != -1 || + errno != EINVAL || memory64_action_is_configured() != 0) { + fprintf(stderr, "memory64 wide sigaction flags mismatch: errno=%d\n", + errno); + return -1; + } + + if (sigaction(SIGUSR2, &original, NULL) != 0) + return fail("memory64 restore sigaction"); + return 0; +} + +/* + * Fork rewind instrumentation is currently a wasm32 artifact contract. The + * memory64 variant still needs to exercise its distinct siginfo_t and public + * struct rusage layouts, so create a same-architecture child through Kandelo's + * non-forking posix_spawn path. The fixture re-enters itself in child mode, + * stops, then waits on an inherited pipe after SIGCONT so the parent cannot + * lose the continued event to a fast exit before inspecting it. + */ +#define MEMORY64_WAIT_CHILD_PATH "/wait-lifecycle-test-wasm64" +#define MEMORY64_WAIT_CHILD_MODE "--memory64-wait-child" + +static int parse_memory64_child_fd(const char *text, int *fd_out) +{ + char trailing; + int fd; + if (sscanf(text, "%d%c", &fd, &trailing) != 1 || fd < 0) { + fprintf(stderr, "memory64 child received invalid fd: %s\n", text); + return -1; + } + *fd_out = fd; + return 0; +} + +static int run_memory64_wait_child(int argc, char **argv) +{ + int read_fd; + int write_fd; + if (argc != 4 || parse_memory64_child_fd(argv[2], &read_fd) != 0 || + parse_memory64_child_fd(argv[3], &write_fd) != 0) + return 118; + if (close(write_fd) != 0) { + fail("memory64 child close write gate"); + return 119; + } + if (raise(SIGSTOP) != 0) { + fail("memory64 child SIGSTOP"); + return 120; + } + char byte = 0; + if (read(read_fd, &byte, 1) != 1) { + fail("memory64 child read gate"); + return 121; + } + if (close(read_fd) != 0) { + fail("memory64 child close read gate"); + return 122; + } + return 7; +} + +static int test_memory64_wait_layouts(void) +{ + int gate[2]; + if (pipe(gate) != 0) + return fail("memory64 gate pipe"); + + char read_fd[16]; + char write_fd[16]; + int read_len = snprintf(read_fd, sizeof(read_fd), "%d", gate[0]); + int write_len = snprintf(write_fd, sizeof(write_fd), "%d", gate[1]); + if (read_len < 0 || (size_t)read_len >= sizeof(read_fd) || + write_len < 0 || (size_t)write_len >= sizeof(write_fd)) { + fprintf(stderr, "memory64 child fd argument was truncated\n"); + close(gate[0]); + close(gate[1]); + return -1; + } + + char *argv[] = { MEMORY64_WAIT_CHILD_PATH, MEMORY64_WAIT_CHILD_MODE, + read_fd, write_fd, NULL }; + pid_t pid = -1; + int spawn_error = posix_spawn(&pid, argv[0], NULL, NULL, argv, environ); + if (spawn_error != 0) { + errno = spawn_error; + close(gate[0]); + close(gate[1]); + return fail("memory64 posix_spawn"); + } + close(gate[0]); + + siginfo_t info; + memset(&info, 0x5a, sizeof(info)); + if (waitid(P_PID, (id_t)pid, &info, WSTOPPED | WNOWAIT) != 0 || + info.si_signo != SIGCHLD || info.si_pid != pid || + info.si_uid != getuid() || info.si_code != CLD_STOPPED || + info.si_status != SIGSTOP) { + fprintf(stderr, + "memory64 waitid stop mismatch: signo=%d pid=%d uid=%u " + "code=%d status=%d\n", + info.si_signo, (int)info.si_pid, (unsigned)info.si_uid, + info.si_code, info.si_status); + close(gate[1]); + return -1; + } + + memset(&info, 0x5a, sizeof(info)); + if (waitid(P_PID, (id_t)pid, &info, WSTOPPED) != 0 || + info.si_code != CLD_STOPPED || info.si_status != SIGSTOP) { + fprintf(stderr, "memory64 consuming waitid stop mismatch\n"); + close(gate[1]); + return -1; + } + + if (kill(pid, SIGCONT) != 0) { + close(gate[1]); + return fail("memory64 SIGCONT"); + } + memset(&info, 0x5a, sizeof(info)); + if (waitid(P_PID, (id_t)pid, &info, WCONTINUED) != 0 || + info.si_signo != SIGCHLD || info.si_pid != pid || + info.si_code != CLD_CONTINUED || info.si_status != SIGCONT) { + fprintf(stderr, + "memory64 waitid continue mismatch: signo=%d pid=%d " + "code=%d status=%d\n", + info.si_signo, (int)info.si_pid, info.si_code, info.si_status); + close(gate[1]); + return -1; + } + + if (write(gate[1], "x", 1) != 1) { + close(gate[1]); + return fail("memory64 release child"); + } + if (close(gate[1]) != 0) + return fail("memory64 close child gate"); + + int status = 0; + struct rusage usage; + memset(&usage, 0xa5, sizeof(usage)); + pid_t got = wait4(pid, &status, 0, &usage); + if (got != pid || !WIFEXITED(status) || WEXITSTATUS(status) != 7 || + expect_zero_rusage(&usage) != 0) { + fprintf(stderr, "memory64 wait4 exit mismatch: got=%d status=%#x\n", + (int)got, status); + return -1; + } + + if (test_getrusage_pointer_validation() != 0 || + test_memory64_sigaction_wire() != 0) + return -1; + return test_siginfo_layout(); +} +#endif + +int main(int argc, char **argv) +{ +#if __SIZEOF_POINTER__ == 8 + if (argc > 1 && strcmp(argv[1], MEMORY64_WAIT_CHILD_MODE) == 0) + return run_memory64_wait_child(argc, argv); +#endif + if (test_cancel_preserves_completed_syscall() != 0) + return 12; +#if __SIZEOF_POINTER__ == 8 + if (test_memory64_wait_layouts() != 0) + return 1; + puts("WAIT_LIFECYCLE_PASS"); + return 0; +#else + (void)argc; + (void)argv; + if (test_wait4_lifecycle() != 0) + return 1; + if (test_waitid_lifecycle() != 0) + return 2; + if (test_sa_nocldstop() != 0) + return 3; + if (test_sigkill_stopped_child() != 0) + return 4; + if (test_getrusage_pointer_validation() != 0) + return 5; + if (test_nonmatching_sigchld_interrupts_wait() != 0) + return 6; + if (test_wait_signal_restart() != 0) + return 7; + if (test_pthread_kill_interrupts_exact_waiter() != 0) + return 8; + if (test_sa_restart_checks_pending_cancel_before_retry() != 0) + return 9; + if (test_siginfo_layout() != 0) + return 10; + if (test_caught_sigcont_precedes_stop_boundary_return() != 0) + return 11; + puts("WAIT_LIFECYCLE_PASS"); + return 0; +#endif +} diff --git a/flake.nix b/flake.nix index ec0c83e342..ba53b423a8 100644 --- a/flake.nix +++ b/flake.nix @@ -43,9 +43,7 @@ llvmPkg.libcxx.dev ]; }; - in { - devShells.default = pkgs.mkShell { - packages = [ + devShellPackages = [ rustToolchain llvmTree # Node 24, not 22: the host code constructs @@ -179,14 +177,19 @@ # cross-build under packages/registry/sqlite/ — that's the # target binary, this is the host CLI used by tests. pkgs.sqlite - ]; + ]; + in { + devShells.default = pkgs.mkShell { + packages = devShellPackages; shellHook = '' # On Darwin, nix develop can leave user profile and - # /opt/homebrew entries ahead of mkShell package bins. Put the - # declared Rust toolchain first so source fallbacks that require - # nightly flags do not accidentally run a host stable cargo. - export PATH="${rustToolchain}/bin:$PATH" + # /opt/homebrew entries ahead of mkShell package bins. Reassert + # the complete declared tool set, not only Rust, so package builds + # cannot silently select host CMake, make, or another ambient + # binary ahead of the flake-pinned version. + export KANDELO_DEV_SHELL_TOOL_PATH="${pkgs.lib.makeBinPath devShellPackages}" + export PATH="$KANDELO_DEV_SHELL_TOOL_PATH:$PATH" export LLVM_BIN=${llvmTree}/bin export LLVM_PREFIX=${llvmTree} export LLVM_VERSION=${llvmVersion} @@ -215,10 +218,11 @@ # (shouldn't happen in this repo, but cheap to guard). __repo_root=$(git rev-parse --show-toplevel 2>/dev/null || echo "$PWD") if [ -d "$__repo_root/sdk/bin" ]; then - case ":$PATH:" in - *:"$__repo_root/sdk/bin":*) ;; - *) export PATH="$__repo_root/sdk/bin:$PATH" ;; - esac + export KANDELO_DEV_SHELL_TOOL_PATH="$__repo_root/sdk/bin:$KANDELO_DEV_SHELL_TOOL_PATH" + export PATH="$__repo_root/sdk/bin:$PATH" + fi + if [ -f "$__repo_root/scripts/check-dev-shell-tools.sh" ]; then + bash "$__repo_root/scripts/check-dev-shell-tools.sh" fi unset __repo_root echo "kandelo dev shell — LLVM ${llvmVersion}, Rust (pinned via rust-toolchain.toml), Node 24, Erlang 28 (minimal), SDK on PATH" diff --git a/host/src/binary-resolver.ts b/host/src/binary-resolver.ts index 8eebfc5e03..f309f24f34 100644 --- a/host/src/binary-resolver.ts +++ b/host/src/binary-resolver.ts @@ -1,5 +1,6 @@ /** - * Resolve a binary (wasm, zip bundle, vfs image) from the repo's + * Resolve a packaged artifact (Wasm executable/side module, VFS image, + * archive, or declared runtime data file) from the repo's * `local-binaries/` or `binaries/` tree. * * Priority: @@ -81,13 +82,14 @@ function packageRoot(): string { } /** - * Resolve a binary relative to the binaries tree. + * Resolve an artifact relative to the binaries tree. * * Example paths: * `kernel.wasm` * `userspace.wasm` * `programs/vim.zip` (implicit wasm32 — see below) * `programs/git/git.wasm` (implicit wasm32) + * `programs/php/icu.dat` (implicit wasm32 runtime file) * `programs/wasm64/mariadb-vfs.vfs.zst` (explicit arch) * * Per-arch layout: `binaries/programs/` and `local-binaries/programs/` @@ -109,20 +111,64 @@ function applyDefaultArch(relPath: string): string { return `programs/wasm32/${tail}`; } -function packagedBinaryCandidates(relPath: string): string[] { - const root = packageRoot(); +function packagedBinaryCandidates( + relPath: string, + root = join(packageRoot(), "wasm"), +): string[] { const adjusted = applyDefaultArch(relPath); - const candidates = [join(root, "wasm", adjusted)]; + const candidates = [join(root, adjusted)]; if (relPath === "kernel.wasm") { - candidates.push(join(root, "wasm", "kandelo-kernel.wasm")); + candidates.push(join(root, "kandelo-kernel.wasm")); } else if (relPath === "userspace.wasm") { - candidates.push(join(root, "wasm", "wasm_posix_userspace.wasm")); + candidates.push(join(root, "wasm_posix_userspace.wasm")); } else if (relPath === "rootfs.vfs") { - candidates.push(join(root, "wasm", "rootfs.vfs")); + candidates.push(join(root, "rootfs.vfs")); } return candidates; } +interface BinaryCandidateTier { + label: string; + root: string; + candidatesFor(relPath: string): string[]; +} + +/** + * Ordered provenance roots used by both single-artifact and package-closure + * resolution. Keeping the grouping explicit lets a closure fall back as a + * unit without ever combining local, fetched, and installed-package bytes. + */ +function binaryCandidateTiers(): BinaryCandidateTier[] { + const tiers: BinaryCandidateTier[] = []; + try { + const repo = findRepoRoot(); + for (const [label, root] of [ + ["local-binaries", join(repo, "local-binaries")], + ["binaries", join(repo, "binaries")], + ] as const) { + tiers.push({ + label, + root, + candidatesFor(relPath: string): string[] { + return [join(root, applyDefaultArch(relPath))]; + }, + }); + } + } catch { + // Installed npm consumers do not carry a source repo root. + } + + const root = join(packageRoot(), "wasm"); + tiers.push({ + label: "installed package", + root, + candidatesFor(relPath: string): string[] { + return packagedBinaryCandidates(relPath, root); + }, + }); + return tiers; +} + let cachedForkInstrumentationDisabledOutputs: Set | null = null; interface ProgramOutputPolicy { @@ -282,22 +328,11 @@ export function resolveBinary(relPath: string): string { const adjusted = applyDefaultArch(relPath); const checked: string[] = []; const candidates: string[] = []; - try { - const repo = findRepoRoot(); - const local = join(repo, "local-binaries", adjusted); - checked.push(local); - candidates.push(local); - const fetched = join(repo, "binaries", adjusted); - checked.push(fetched); - candidates.push(fetched); - } catch { - // Installed npm consumers do not have a source repo root. Fall - // through to packaged assets below. - } - const packaged = packagedBinaryCandidates(relPath); - for (const candidate of packaged) { - checked.push(candidate); - candidates.push(candidate); + for (const tier of binaryCandidateTiers()) { + for (const candidate of tier.candidatesFor(relPath)) { + checked.push(candidate); + candidates.push(candidate); + } } const candidate = chooseBinaryCandidate(candidates, relPath); if (candidate) return candidate; @@ -320,6 +355,53 @@ export function tryResolveBinary(relPath: string): string | null { } } +/** + * Resolve a related artifact set from one complete provenance tier. + * + * A partial or policy-invalid local tier is skipped as a whole when a later + * tier is complete. If artifacts exist across the candidate roots but no + * single root contains an accepted complete set, this throws instead of + * silently composing a package from unrelated builds. It returns `null` only + * when none of the requested artifacts exists in any tier. + * + * Returned paths preserve `relPaths` order and are guaranteed to share the + * same local, fetched, or installed-package root. + */ +export function tryResolveBinarySet(relPaths: readonly string[]): string[] | null { + if (relPaths.length === 0) return []; + + let anyExisting = false; + const incomplete: string[] = []; + for (const tier of binaryCandidateTiers()) { + const selected: string[] = []; + const unavailable: string[] = []; + for (const relPath of relPaths) { + const candidates = tier.candidatesFor(relPath); + const existing = candidates.filter((candidate) => existsSync(candidate)); + anyExisting ||= existing.length > 0; + const candidate = chooseBinaryCandidate(candidates, relPath); + if (candidate) { + selected.push(candidate); + } else if (existing.length > 0) { + unavailable.push(`${relPath} (rejected by artifact policy)`); + } else { + unavailable.push(`${relPath} (missing)`); + } + } + if (unavailable.length === 0) return selected; + incomplete.push( + ` ${tier.label} (${tier.root}): ${unavailable.join(", ")}`, + ); + } + + if (!anyExisting) return null; + throw new Error( + "Package artifact closure is incomplete: no single provenance tier " + + "contains every accepted artifact, and tiers will not be mixed.\n" + + incomplete.join("\n"), + ); +} + /** Returns the absolute path of binaries/ whether or not it exists. */ export function binariesDir(): string { return join(findRepoRoot(), "binaries"); diff --git a/host/src/browser-kernel-host.ts b/host/src/browser-kernel-host.ts index 8fb880e993..2e774ee68a 100644 --- a/host/src/browser-kernel-host.ts +++ b/host/src/browser-kernel-host.ts @@ -14,8 +14,10 @@ import { import { FramebufferRegistry } from "./framebuffer/registry"; import type { ProcessSnapshot, SyscallTraceEvent } from "./kernel-worker"; import type { + HostDiagnostic, MainToKernelMessage, KernelToMainMessage, + VfsFileSnapshot, } from "./browser-kernel-protocol"; import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http"; @@ -42,6 +44,8 @@ export interface BrowserKernelOptions { onStdout?: (data: Uint8Array) => void; /** Called when a process writes to stderr */ onStderr?: (data: Uint8Array) => void; + /** Called for host-runtime diagnostics that are not guest stderr. */ + onHostDiagnostic?: (diagnostic: HostDiagnostic) => void; /** Called when a process requests a TCP listener (for service worker bridging) */ onListenTcp?: (pid: number, fd: number, port: number) => void; /** Called when the service-worker HTTP bridge gains or completes preview requests. */ @@ -52,9 +56,10 @@ export interface BrowserKernelOptions { * Used by Inspector-style UIs to refresh their process table without * polling. Source feeds: * - main-thread BrowserKernel.spawn / .boot → "spawn" - * - worker-side fork / posix_spawn → "spawn" (via proc_event message) + * - worker-side fork / posix_spawn → "spawn" with `ppid` (via proc_event message) * - worker-side execve → "exec" * - worker-side exit → "exit" (via existing exit message) + * Main-thread root spawns do not carry `ppid`. */ onProcessEvent?: (event: { kind: "spawn" | "exec" | "exit"; pid: number; ppid?: number; exitStatus?: number }) => void; /** Pre-compiled thread module for clone(). Avoids recompiling large wasm for each thread. */ @@ -74,6 +79,10 @@ export interface BrowserKernelOptions { syscallLogPtrWidth?: 4 | 8; /** Forwarded to TlsNetworkBackendOptions.dnsAliases. */ dnsAliases?: Record; + /** Forwarded to TlsNetworkBackendOptions.corsProxyUrl. Browser pages that + * are not controlled by Kandelo's service worker can use this to route + * guest outbound HTTP(S) through a same-origin proxy. */ + corsProxyUrl?: string; } /** Options for {@link BrowserKernel.boot}. */ @@ -128,6 +137,8 @@ export class BrowserKernel { > & BrowserKernelOptions; private exitResolvers = new Map void>(); + private unclaimedExitStatuses = new Map(); + private exitSequence = 0; private pendingRequests = new Map void; reject: (err: Error) => void }>(); private nextRequestId = 1; private ptyOutputCallbacks = new Map void>(); @@ -233,13 +244,26 @@ export class BrowserKernel { this.handleWorkerMessage(e.data as KernelToMainMessage); }; this.kernelWorkerHandle.onerror = (e: ErrorEvent) => { - console.error("[BrowserKernel] Kernel worker error:", e.message); const err = new Error(`Kernel worker error: ${e.message}`); for (const [, { reject }] of this.pendingRequests) { reject(err); } this.pendingRequests.clear(); this.options.onHttpBridgePendingRequests?.(0); + const diagnostic: HostDiagnostic = { + pid: 0, + source: "kernel worker", + message: `[BrowserKernel] kernel worker error: ${e.message}`, + }; + // A worker-level error cannot send a typed message itself. Preserve the + // same callback contract and a visible default without treating the + // failure as guest stderr. + console.error(diagnostic.message); + try { + this.options.onHostDiagnostic?.(diagnostic); + } catch (callbackError) { + console.error("[BrowserKernel] onHostDiagnostic callback failed:", callbackError); + } }; await new Promise((resolve, reject) => { @@ -295,6 +319,7 @@ export class BrowserKernel { enableSyscallLog: this.options.enableSyscallLog, syscallLogPtrWidth: this.options.syscallLogPtrWidth, dnsAliases: this.options.dnsAliases, + corsProxyUrl: this.options.corsProxyUrl, }, }; this.kernelWorkerHandle.postMessage(initMsg, [transferBuf]); @@ -310,6 +335,7 @@ export class BrowserKernel { options: BrowserKernelBootOptions, ): Promise<{ pid: number; exit: Promise }> { const requestId = this.nextRequestId++; + const spawnStartedBeforeExitSequence = this.exitSequence; const stdin = options.stdin ?? (!options.pty ? new Uint8Array() : undefined); const pid = await this.request(requestId, { @@ -327,9 +353,7 @@ export class BrowserKernel { maxPages: this.maxPages, }) as number; - const exit = new Promise((resolve) => { - this.exitResolvers.set(pid, resolve); - }); + const exit = this.claimExitStatus(pid, spawnStartedBeforeExitSequence); if (options.pty) { this.sendToKernel({ type: "register_pty_output", pid }); @@ -449,6 +473,7 @@ export class BrowserKernel { }, ): Promise<{ pid: number; exit: Promise }> { const requestId = this.nextRequestId++; + const spawnStartedBeforeExitSequence = this.exitSequence; const pid = await this.request(requestId, { type: "spawn", requestId, @@ -465,9 +490,7 @@ export class BrowserKernel { maxPages: this.maxPages, }) as number; - const exit = new Promise((resolve) => { - this.exitResolvers.set(pid, resolve); - }); + const exit = this.claimExitStatus(pid, spawnStartedBeforeExitSequence); if (options?.pty) { this.sendToKernel({ type: "register_pty_output", pid }); @@ -836,9 +859,8 @@ export class BrowserKernel { /** * Read a file out of the kernel-owned VFS from the main thread. Returns the * bytes, or `null` if the path does not exist / is not readable. This is the - * only main-thread window into the worker-owned FS — use it to collect - * artifacts a process wrote (the main thread no longer shares the VFS - * SharedArrayBuffer in kernel-owned mode). + * readback path for collecting artifacts a process wrote; the main thread + * never receives the live VFS SharedArrayBuffer. */ async readFileFromVfs(path: string): Promise { const requestId = this.nextRequestId++; @@ -850,6 +872,58 @@ export class BrowserKernel { return (result as Uint8Array | null) ?? null; } + /** + * Read a file and its permission bits from the worker-owned VFS. This is + * useful for callers that temporarily replace a path between process spawns + * and must restore the exact prior state afterward. + */ + async readFileSnapshotFromVfs(path: string): Promise { + const requestId = this.nextRequestId++; + const result = await this.request(requestId, { + type: "read_vfs_file", + requestId, + path, + includeMode: true, + }); + return (result as VfsFileSnapshot | null) ?? null; + } + + /** + * Create or replace a regular file in the worker-owned VFS. The mutation is + * performed by the kernel worker, preserving exclusive VFS ownership; call + * this only while guest processes that could access the path are stopped. + * The parent directory must already exist. + */ + async writeFileToVfs( + path: string, + data: Uint8Array, + mode = 0o644, + ): Promise { + const requestId = this.nextRequestId++; + const owned = data.slice(); + await this.request(requestId, { + type: "write_vfs_file", + requestId, + path, + data: owned, + mode: mode & 0o7777, + }, [owned.buffer]); + } + + /** + * Remove a path from the worker-owned VFS between process spawns. Returns + * false when the path did not exist. + */ + async unlinkFileFromVfs(path: string): Promise { + const requestId = this.nextRequestId++; + const result = await this.request(requestId, { + type: "unlink_vfs_file", + requestId, + path, + }); + return result === true; + } + /** Destroy the kernel and release all resources. */ async destroy(): Promise { const requestId = this.nextRequestId++; @@ -859,6 +933,7 @@ export class BrowserKernel { }); this.kernelWorkerHandle.terminate(); this.exitResolvers.clear(); + this.unclaimedExitStatuses.clear(); this.pendingRequests.clear(); this.ptyOutputCallbacks.clear(); this.options.onHttpBridgePendingRequests?.(0); @@ -878,9 +953,9 @@ export class BrowserKernel { // ── Private helpers ── - /** Ensure SSL cert env vars are present in the environment array. - * These are needed because OpenSSL's compiled-in openssldir is a host - * path that doesn't exist in the Wasm VFS. */ + /** Ensure SSL cert env vars select the image-owned platform trust bundle. + * OpenSSL's configured directory is `/etc/ssl`; browser sessions replace + * only this CA-bundle path with their ephemeral MITM root. */ private mergeEnv(env: string[]): string[] { const sslVars = [ "SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt", @@ -912,6 +987,17 @@ export class BrowserKernel { this.kernelWorkerHandle.postMessage(msg, transfer ?? []); } + private claimExitStatus(pid: number, spawnStartedBeforeExitSequence: number): Promise { + const unclaimed = this.unclaimedExitStatuses.get(pid); + this.unclaimedExitStatuses.delete(pid); + if (unclaimed !== undefined && unclaimed.sequence > spawnStartedBeforeExitSequence) { + return Promise.resolve(unclaimed.status); + } + return new Promise((resolve) => { + this.exitResolvers.set(pid, resolve); + }); + } + private request(requestId: number, msg: MainToKernelMessage, transfer?: Transferable[]): Promise { return new Promise((resolve, reject) => { this.pendingRequests.set(requestId, { resolve, reject }); @@ -928,6 +1014,12 @@ export class BrowserKernel { private handleWorkerMessage(msg: KernelToMainMessage): void { switch (msg.type) { + case "ready": + case "init_error": + // The temporary boot listener resolves or rejects initialization. The + // permanent listener also receives these messages, so account for + // them explicitly rather than relying on an implicit fall-through. + break; case "response": { const pending = this.pendingRequests.get(msg.requestId); if (pending) { @@ -942,8 +1034,20 @@ export class BrowserKernel { } case "exit": { const resolver = this.exitResolvers.get(msg.pid); - this.exitResolvers.delete(msg.pid); - if (resolver) resolver(msg.status); + if (resolver) { + this.exitResolvers.delete(msg.pid); + resolver(msg.status); + } else { + this.unclaimedExitStatuses.set(msg.pid, { + status: msg.status, + sequence: ++this.exitSequence, + }); + while (this.unclaimedExitStatuses.size > 256) { + const oldest = this.unclaimedExitStatuses.keys().next().value; + if (oldest === undefined) break; + this.unclaimedExitStatuses.delete(oldest); + } + } this.options.onProcessEvent?.({ kind: "exit", pid: msg.pid, exitStatus: msg.status }); break; } @@ -952,7 +1056,10 @@ export class BrowserKernel { // don't come through BrowserKernel.spawn(), so the worker posts // them directly. Exit is delivered separately via the existing // "exit" message above. - this.options.onProcessEvent?.({ kind: msg.kind, pid: msg.pid, ppid: msg.ppid }); + const event = msg.kind === "spawn" + ? { kind: msg.kind, pid: msg.pid, ppid: msg.ppid } + : { kind: msg.kind, pid: msg.pid }; + this.options.onProcessEvent?.(event); break; } case "http_bridge_pending": @@ -964,6 +1071,15 @@ export class BrowserKernel { case "stderr": this.options.onStderr?.(msg.data); break; + case "host_diagnostic": { + this.options.onHostDiagnostic?.({ + pid: msg.pid, + source: msg.source, + message: msg.message, + ...(msg.status === undefined ? {} : { status: msg.status }), + }); + break; + } case "pty_output": { const cb = this.ptyOutputCallbacks.get(msg.pid); if (cb) { @@ -1010,6 +1126,17 @@ export class BrowserKernel { case "lazy_download": this.emitLazyDownload(msg.event); break; + default: { + // Keep this dispatch coupled to KernelToMainMessage as the protocol + // grows. Runtime values still originate outside TypeScript, so make a + // malformed/unknown worker message visible instead of dropping it. + const exhaustive: never = msg; + void exhaustive; + console.error( + `[BrowserKernel] unknown kernel-worker message type: ${String((msg as { type?: unknown }).type)}`, + ); + break; + } } } diff --git a/host/src/browser-kernel-protocol.ts b/host/src/browser-kernel-protocol.ts index c70b181eac..adf373a107 100644 --- a/host/src/browser-kernel-protocol.ts +++ b/host/src/browser-kernel-protocol.ts @@ -9,8 +9,12 @@ import type { HttpResponse, } from "./networking/in-kernel-http"; import type { LazyDownloadEvent } from "./vfs/memory-fs"; +import type { + HostDiagnosticMessage, +} from "./host-diagnostic"; export type { HttpRequest, HttpResponse }; +export type { HostDiagnostic } from "./host-diagnostic"; // ── Main Thread → Kernel Worker ── @@ -43,6 +47,10 @@ export interface InitMessage { syscallLogPtrWidth?: 4 | 8; /** Forwarded to TlsNetworkBackendOptions.dnsAliases. */ dnsAliases?: Record; + /** Forwarded to TlsNetworkBackendOptions.corsProxyUrl for browser fetch + * backends that need a same-origin proxy to reach external HTTP(S) + * hosts. */ + corsProxyUrl?: string; }; } @@ -83,10 +91,31 @@ export interface TerminateProcessMessage { status: number; } +export interface VfsFileSnapshot { + data: Uint8Array; + mode: number; +} + export interface ReadVfsFileMessage { type: "read_vfs_file"; requestId: number; path: string; + /** Return the file's permission bits with its bytes for lossless restore. */ + includeMode?: boolean; +} + +export interface WriteVfsFileMessage { + type: "write_vfs_file"; + requestId: number; + path: string; + data: Uint8Array; + mode: number; +} + +export interface UnlinkVfsFileMessage { + type: "unlink_vfs_file"; + requestId: number; + path: string; } export interface AppendStdinDataMessage { @@ -321,6 +350,8 @@ export type MainToKernelMessage = | SpawnMessage | TerminateProcessMessage | ReadVfsFileMessage + | WriteVfsFileMessage + | UnlinkVfsFileMessage | AppendStdinDataMessage | SetStdinDataMessage | PtyWriteMessage @@ -454,15 +485,12 @@ export interface FbWriteMessage { * Posted whenever the kernel forks, execs, or spawns. The main thread * uses this to refresh Inspector-style views without polling. `kind === * "exit"` is delivered via the existing ExitMessage instead; we don't - * duplicate it here. + * duplicate it here. Spawn events always carry the authoritative parent pid; + * exec events preserve process identity and do not. */ -export interface ProcEventMessage { - type: "proc_event"; - kind: "spawn" | "exec"; - pid: number; - /** Parent pid for fork-style spawns. Omitted for execs. */ - ppid?: number; -} +export type ProcEventMessage = + | { type: "proc_event"; kind: "spawn"; pid: number; ppid: number } + | { type: "proc_event"; kind: "exec"; pid: number }; /** * Number of service-worker preview requests currently being served through @@ -485,6 +513,7 @@ export type KernelToMainMessage = | ExitMessage | StdoutMessage | StderrMessage + | HostDiagnosticMessage | PtyOutputMessage | ListenTcpMessage | FbBindMessage diff --git a/host/src/browser-kernel-worker-entry.ts b/host/src/browser-kernel-worker-entry.ts index 16e4143aba..3567df1039 100644 --- a/host/src/browser-kernel-worker-entry.ts +++ b/host/src/browser-kernel-worker-entry.ts @@ -60,7 +60,12 @@ if (typeof globalThis.setImmediate === "undefined") { }; } -import { CAPTURED_STDIO, CentralizedKernelWorker, TERMINAL_STDIO } from "./kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + isCurrentProcessGeneration, + TERMINAL_STDIO, +} from "./kernel-worker"; import type { ForkFromThreadContext, ResolvedSpawnProgram, @@ -68,6 +73,7 @@ import type { } from "./kernel-worker"; import type { KernelPointer } from "./kernel"; import { BrowserWorkerAdapter } from "./worker-adapter-browser"; +import { DeferredWorkerHandle } from "./deferred-worker-handle"; import { VirtualPlatformIO } from "./vfs/vfs"; import { MemoryFileSystem } from "./vfs/memory-fs"; import { DeviceFileSystem } from "./vfs/device-fs"; @@ -79,7 +85,7 @@ import { import type { MountConfig } from "./vfs/types"; import { TlsNetworkBackend } from "./networking/tls-network-backend"; import { patchWasmForThread } from "./worker-main"; -import { detectPtrWidth, extractHeapBase, isWasmModuleBytes } from "./constants"; +import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; import { classifiedSignalOrFallback, @@ -87,7 +93,11 @@ import { signalExitStatus, SIGSEGV, } from "./trap-signals"; -import { threadWorkerFailureDisposition } from "./thread-worker-disposition"; +import { + removeThreadWorkerRegistryEntry, + threadWorkerFailureDisposition, +} from "./thread-worker-disposition"; +import { VmInterruptTimerManager } from "./vm-interrupt-timer"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, @@ -103,6 +113,7 @@ import { type ProcessMemoryLayout, } from "./process-memory"; import type { + HostDiagnostic, MainToKernelMessage, KernelToMainMessage, } from "./browser-kernel-protocol"; @@ -150,7 +161,10 @@ interface ProcessInfo { forkReplayContext?: ForkReplayContext; } const processes = new Map(); -const processTeardowns = new Map>(); +const processTeardowns = new Map>(); +const vmInterruptTimers = new VmInterruptTimerManager( + (pid) => processes.get(pid), +); // Includes standalone thread-worker teardown promises that may outlive the // process map entry they came from. const workerTeardowns = new Set>(); @@ -200,7 +214,18 @@ async function resolveExecutableForLaunch( const shebang = parseShebang(bytes); if (!shebang) { if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC }; - return { programBytes: bytes, argv }; + let programModule: WebAssembly.Module; + try { + programModule = await WebAssembly.compile(bytes); + } catch (error) { + if (error instanceof WebAssembly.CompileError) return { errno: ENOEXEC }; + throw error; + } + const declaredAbi = extractAbiVersion(bytes); + if (declaredAbi !== null && declaredAbi !== kernelWorker.getKernelAbiVersion()) { + return { errno: ENOEXEC }; + } + return { programBytes: bytes, programModule, argv }; } const scriptArgv = [ @@ -226,6 +251,16 @@ const threadWorkers = new Map(); const threadExits = new ThreadExitCoordinator(); const reportedNonzeroProcessExits = new Set(); +function handleVmInterruptTimer(msg: { + pid: number; + timedOutPtr: number; + vmInterruptPtr: number; + seconds: number; +}, pid: number, process: ProcessInfo): void { + if (msg.pid !== pid) return; + vmInterruptTimers.handleRequest(pid, process, msg); +} + function delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } @@ -259,8 +294,12 @@ function reportNonzeroProcessExitDiagnostic( `[kernel-worker] nonzero process exit pid=${pid} status=${status} source=${source} argv=${JSON.stringify(info?.argv ?? [])}` + (serviceLog ? `\n${serviceLog}` : "") + `\n${syscalls}`; - console.warn(diagnostic); - post({ type: "stderr", pid, data: new TextEncoder().encode(`${diagnostic}\n`) }); + reportHostDiagnostic({ + pid, + status, + source, + message: diagnostic, + }, "warn"); } function readServiceLogForProcess(argv: readonly string[] | undefined): string | null { @@ -300,6 +339,9 @@ async function terminateThreadWorkers(pid: number): Promise { const threads = threadWorkers.get(pid); if (!threads) return; threadWorkers.delete(pid); + for (const thread of threads) { + intentionallyTerminated.add(thread.worker as object); + } for (const t of threads) { await ( t.termination ?? @@ -324,6 +366,15 @@ function post(msg: KernelToMainMessage, transfer?: Transferable[]) { (globalThis as any).postMessage(msg, transfer ?? []); } +function reportHostDiagnostic( + diagnostic: HostDiagnostic, + level: "error" | "warn" = "error", +): void { + if (level === "warn") console.warn(diagnostic.message); + else console.error(diagnostic.message); + post({ type: "host_diagnostic", ...diagnostic }); +} + function reportBridgePendingRequests(): void { post({ type: "http_bridge_pending", count: activeBridgeRequests.size }); } @@ -380,11 +431,10 @@ function respondErrorIfRequested( } function reportWorkerProtocolError(message: string): void { - console.error(`[kernel-worker] ${message}`); - post({ - type: "stderr", + reportHostDiagnostic({ pid: 0, - data: new TextEncoder().encode(`[kernel-worker] ${message}\n`), + source: "worker protocol", + message: `[kernel-worker] ${message}`, }); } @@ -617,6 +667,7 @@ async function handleInit(msg: Extract) { // production, keeping the browser networking path identical across modes. const tlsBackend = new TlsNetworkBackend({ dnsAliases: msg.config.dnsAliases, + corsProxyUrl: msg.config.corsProxyUrl, }); await tlsBackend.init(); io.network = tlsBackend; @@ -657,12 +708,22 @@ async function handleInit(msg: Extract) { post({ type: "proc_event", kind: "spawn", pid: childPid, ppid: parentPid }); return handleFork(parentPid, childPid, parentMemory, threadFork); }, - onExec: async (pid, path, argv, envp) => { - const result = await handleExec(pid, path, argv, envp); + onExec: async (pid, path, argv, envp, callerTid) => { + const previousWorker = processes.get(pid)?.worker; + const result = await handleExec(pid, path, argv, envp, callerTid); // Fire after handleExec updates the kernel Process.argv. If this is // sent before registerProcess(..., { argv }), Kandelo's Procs tab // refreshes against stale cmdline data and only corrects on remount. - if (result === 0) post({ type: "proc_event", kind: "exec", pid }); + // Fatal post-commit handoffs return 0 too, but install no new worker. + const installedWorker = processes.get(pid)?.worker; + if ( + result === 0 + && installedWorker + && installedWorker !== previousWorker + && kernelWorker.isProcessExecutionActive(pid) + ) { + post({ type: "proc_event", kind: "exec", pid }); + } return result; }, onResolveSpawn: handlePosixSpawnResolve, @@ -677,11 +738,11 @@ async function handleInit(msg: Extract) { // In a dedicated worker, use Atomics.waitAsync directly — no V8 microtask // chain freeze bug (that's main-thread-only). kernelWorker.usePolling = false; - // Process a small batch of syscalls via microtask before yielding to the - // event loop via setImmediate. Batch size 8 is a good balance: it gives - // ~8x throughput vs batch-1 while still yielding frequently enough for - // pump timers, message handlers, and rendering to interleave. - (kernelWorker as any).relistenBatchSize = 8; + // Defer every relisten and already-PENDING dispatch through the + // MessageChannel-backed setImmediate queue so syscall handling and worker + // messages both keep progressing under multi-process bridge load. + // Notification remains event-driven through Atomics.waitAsync. + (kernelWorker as any).relistenBatchSize = 1; // Inject stdout/stderr/listen callbacks const kw = kernelWorker as any; @@ -770,6 +831,7 @@ async function handleInit(msg: Extract) { // ── Spawn ── async function handleSpawn(msg: Extract) { + let registeredPid: number | undefined; try { await waitForProcessTeardowns(); @@ -810,20 +872,23 @@ async function handleSpawn(msg: Extract) argv: msg.argv, }); const channelOffset = layout.channelOffset; + const launchEnv = msg.env ?? defaultEnv; kernelWorker.registerProcess(pid, memory, [channelOffset], { ptrWidth, argv: msg.argv, + env: launchEnv, brkBase: layout.brkBase, mmapBase: layout.mmapBase, maxAddr: layout.maxAddr, stdio: msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, }); + registeredPid = pid; + kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); if (msg.cwd) { kernelWorker.setCwd(pid, msg.cwd); } - kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); if (msg.pty) { const ptyIdx = kernelWorker.setupPty(pid); @@ -849,7 +914,7 @@ async function handleSpawn(msg: Extract) programBytes, memory, channelOffset, - env: msg.env ?? defaultEnv, + env: launchEnv, argv: msg.argv, cwd: msg.cwd, ptrWidth, @@ -869,9 +934,13 @@ async function handleSpawn(msg: Extract) }); installProcessWorkerListeners(worker, pid); + registeredPid = undefined; respond(msg.requestId, pid); } catch (e) { + if (registeredPid !== undefined) { + kernelWorker.unregisterProcess(registeredPid); + } respondError(msg.requestId, String(e)); } } @@ -902,16 +971,15 @@ function installProcessWorkerListeners( let exited = false; const finalize = (status: number, source: string, crashSignum?: number) => { if (exited) return; - exited = true; + if (intentionallyTerminated.has(worker as object)) return; if (processes.get(pid)?.worker !== worker) return; // already replaced (e.g. by exec) + exited = true; const message = `[kernel-worker] pid=${pid} ${source} -> forcing exit ${status}`; if (status === 0 && source === "worker-main exit message") { console.debug(message); - } else { - console.warn(message); } reportNonzeroProcessExitDiagnostic(pid, status, source); - handleExit(pid, status, crashSignum); + handleExit(pid, status, crashSignum, worker); }; // Status conventions match the Node host: @@ -922,9 +990,15 @@ function installProcessWorkerListeners( // m.status — worker-main posted {type:"exit"}, normal exit path. worker.on("error", (err: Error) => { if (intentionallyTerminated.has(worker as object)) return; - console.error(`[kernel-worker] Worker error pid=${pid}:`, err.message); const signum = classifiedSignalOrFallback(err); - finalize(signalExitStatus(signum), "worker.onerror", signum); + const status = signalExitStatus(signum); + reportHostDiagnostic({ + pid, + status, + source: "worker.onerror", + message: `[kernel-worker] worker error pid=${pid}: ${err.message}`, + }); + finalize(status, "worker.onerror", signum); }); worker.on("exit", (code: number) => { // BrowserWorkerHandle synthesizes an "exit" event when the underlying @@ -941,23 +1015,24 @@ function installProcessWorkerListeners( finalize(signalExitStatus(SIGSEGV), "worker exit event", SIGSEGV); }); worker.on("message", (msg: unknown) => { - const m = msg as { type?: string; message?: string; pid?: number; status?: number }; + if (intentionallyTerminated.has(worker as object)) return; + const process = processes.get(pid); + if (!process || process.worker !== worker) return; + const m = msg as WorkerToHostMessage; if (m.type === "error") { - console.error(`[kernel-worker] Process error pid=${pid}:`, m.message); - // Forward to host stderr so the demo log shows the actual failure - // ("Kernel worker failed: ..." with the wasm trap or - // instantiation error). Without this, a process death in the - // browser is invisible to the user — only console.error in the - // kernel-worker scope, which most users don't open. Mirrors the - // Node-side handleSpawn message-listener stderr forwarding. - const errBytes = new TextEncoder().encode( - `[process-worker] ${m.message ?? "unknown error"}\n`, - ); - post({ type: "stderr", pid, data: errBytes }); const signum = classifiedSignalOrFallback(m.message); - finalize(classifiedTrapExitStatus(m.message) ?? -1, "worker-main error message", signum); + const status = classifiedTrapExitStatus(m.message) ?? -1; + reportHostDiagnostic({ + pid, + status, + source: "worker-main error message", + message: `[process-worker] ${m.message ?? "unknown error"}`, + }); + finalize(status, "worker-main error message", signum); } else if (m.type === "exit") { finalize(m.status ?? 0, "worker-main exit message"); + } else if (m.type === "vm_interrupt_timer") { + handleVmInterruptTimer(m, pid, process); } }); } @@ -970,15 +1045,20 @@ async function handleFork( parentMemory: WebAssembly.Memory, threadFork?: ForkFromThreadContext, ): Promise { - await waitForProcessTeardowns(); - const parentInfo = processes.get(parentPid); - if (!parentInfo) throw new Error(`Unknown parent pid ${parentPid}`); + if (!parentInfo || parentInfo.memory !== parentMemory) { + throw new Error(`Unknown parent generation for pid ${parentPid}`); + } + + // Capture the exact program/layout generation associated with the kernel's + // already-created child before yielding to unrelated worker teardowns. + await waitForProcessTeardowns(); // Pre-compile module for TurboFan-optimized code (smaller stack frames). if (!parentInfo.programModule) { parentInfo.programModule = await WebAssembly.compile(parentInfo.programBytes); } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return []; const parentBuf = new Uint8Array(parentMemory.buffer); const parentPages = Math.ceil(parentBuf.byteLength / PAGE_SIZE); @@ -1000,6 +1080,7 @@ async function handleFork( maxAddr: childLayout.maxAddr, mmapBase: childLayout.mmapBase, }); + kernelWorker.inheritProcessSharedMappings(parentPid, childPid); const forkReplayContext: ForkReplayContext | undefined = threadFork ? { @@ -1025,7 +1106,9 @@ async function handleFork( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const childWorker = workerAdapter.createWorker(childInitData); + const childWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(childInitData), + ); processes.set(childPid, { memory: childMemory, @@ -1042,6 +1125,27 @@ async function handleFork( installProcessWorkerListeners(childWorker, childPid); + try { + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + childPid, + childMemory, + () => { childWorker.start(); }, + () => { void childWorker.terminate(); }, + ); + if (startDisposition === "stale") { + throw new Error(`Fork child ${childPid} changed generation before Worker launch`); + } + } catch (error) { + if (processes.get(childPid)?.worker === childWorker) { + processes.delete(childPid); + threadModuleCache.delete(childPid); + ptyByPid.delete(childPid); + vmInterruptTimers.clear(childPid); + } + void childWorker.terminate(); + throw error; + } + return [childChannelOffset]; } @@ -1050,108 +1154,191 @@ async function handleExec( path: string, argv: string[], envp: string[], + callerTid: number, ): Promise { + const initiatingInfo = processes.get(pid); + if (!initiatingInfo) return -3; // ESRCH + if (!kernelWorker.supportsExecMetadataReplacement()) return -38; // ENOSYS + const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; - const { programBytes: bytes, argv: launchArgv } = resolved; - - // Program found — run kernel exec setup - const setupResult = kernelWorker.kernelExecSetup(pid); - if (setupResult < 0) return setupResult; - - kernelWorker.prepareProcessForExec(pid); - - // Terminate old worker. Mark it as intentionally terminated *before* - // calling terminate(): the synthesized "exit" event from - // BrowserWorkerHandle would otherwise fire installProcessWorkerListeners' - // crash detector and tear down the kernel's view of the still-alive - // (post-exec) process. - const oldInfo = processes.get(pid); - if (oldInfo?.worker) { - intentionallyTerminated.add(oldInfo.worker as object); - await oldInfo.worker.terminate().catch(() => {}); + const { programBytes: bytes, programModule, argv: launchArgv } = resolved; + // Preallocate the replacement address space before the irreversible commit. + const ptrWidth = detectPtrWidth(bytes); + const metadataResult = kernelWorker.validateExecMetadata( + launchArgv, + envp, + initiatingInfo.ptrWidth, + ); + if (metadataResult < 0) return metadataResult; + let prepared: ReturnType; + try { + prepared = createFreshProcessMemory(pid, bytes, ptrWidth, maxPages, { + operation: "exec", + path, + argv: launchArgv, + }); + } catch { + return -12; // ENOMEM } - // DIAGNOSTIC: track pid → exec path so the sysprof dump can name - // each pid (otherwise the table is just opaque numbers). - { - const g = globalThis as { __pidMap?: Map }; - if (!g.__pidMap) g.__pidMap = new Map(); - g.__pidMap.set(pid, path); - } - // Create fresh memory sized for the new binary's arch (exec across - // wasm32↔wasm64 replaces the process image — memory type must match). - const ptrWidth = detectPtrWidth(bytes); - const { - memory: newMemory, - layout: newLayout, - threadAllocator: newThreadAllocator, - } = createFreshProcessMemory(pid, bytes, ptrWidth, maxPages, { - operation: "exec", - path, - argv: launchArgv, - }); - const newChannelOffset = newLayout.channelOffset; + // Resolution/compilation yielded to the event loop. The numeric pid may + // now name a replacement generation; a stale continuation must not commit + // exec state against it. + if (processes.get(pid) !== initiatingInfo + || kernelWorker.isExecHandoffActive(pid) + || !kernelWorker.isProcessExecutionActive(pid)) return -3; // ESRCH + const prepareResult = kernelWorker.kernelExecPrepare(pid, callerTid); + if (prepareResult < 0) return prepareResult; + const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(pid); + if (addressSpaceResult < 0) return addressSpaceResult; + let replacementWorker: ReturnType | undefined; + try { + const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); + if (setupResult < 0) return setupResult; + vmInterruptTimers.clear(pid); + + // Invalidate the discarded image synchronously at the commit point. This + // keeps stale clone/listener continuations out even if later detach or + // replacement-worker setup fails. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + threadedProcessPids.delete(pid); + kernelWorker.prepareProcessForExec(pid); - kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - skipKernelCreate: true, - ptrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - // Refresh the kernel's Process.argv so /proc//cmdline and - // host-side enumeration (Kandelo Inspector → Procs) show the new - // program's argv after exec, not the parent's pre-exec argv. - argv: launchArgv, - }); + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const execInitData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - ppid: 0, - programBytes: bytes, - memory: newMemory, - channelOffset: newChannelOffset, - argv: launchArgv, - env: envp, - ptrWidth, - kernelAbiVersion: kernelWorker.getKernelAbiVersion(), - }; + await terminateThreadWorkers(pid); + if (initiatingInfo.worker) { + await initiatingInfo.worker.terminate().catch(() => {}); + } + if (kernelWorker.finalizeExecHandoffTermination(pid) > 0) return 0; - const newWorker = workerAdapter.createWorker(execInitData); + // DIAGNOSTIC: track pid → exec path so the sysprof dump can name + // each pid (otherwise the table is just opaque numbers). + { + const g = globalThis as { __pidMap?: Map }; + if (!g.__pidMap) g.__pidMap = new Map(); + g.__pidMap.set(pid, path); + } + const { + memory: newMemory, + layout: newLayout, + threadAllocator: newThreadAllocator, + } = prepared; + const newChannelOffset = newLayout.channelOffset; - // Clear cached thread module — the new program binary is different - threadModuleCache.delete(pid); + const execInitData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid, + ppid: 0, + programBytes: bytes, + programModule, + memory: newMemory, + channelOffset: newChannelOffset, + argv: launchArgv, + env: envp, + ptrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; - processes.set(pid, { - memory: newMemory, - programBytes: bytes, - worker: newWorker, - argv: launchArgv, - channelOffset: newChannelOffset, - ptrWidth, - layout: newLayout, - threadAllocator: newThreadAllocator, - }); + kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { + skipKernelCreate: true, + ptrWidth, + metadataPtrWidth: initiatingInfo.ptrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + // Refresh kernel-owned argv/environment for procfs and kernel APIs. + argv: launchArgv, + env: envp, + }); + replacementWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(execInitData), + ); + + // Clear cached thread module — the new program binary is different + threadModuleCache.delete(pid); - // Wire post-exec error/exit handling. The handleFork listener (on the - // pre-exec worker) is gone with the terminated worker; without re-arming - // here, a wasm trap in the exec'd binary (php-fpm child handling a slow - // request, sed inside wp-config-init, etc.) leaves the kernel believing - // the process is alive and the parent's waitpid blocks forever. - installProcessWorkerListeners(newWorker, pid); + processes.set(pid, { + memory: newMemory, + programBytes: bytes, + programModule, + worker: replacementWorker, + argv: launchArgv, + channelOffset: newChannelOffset, + ptrWidth, + layout: newLayout, + threadAllocator: newThreadAllocator, + }); - return 0; + // Wire post-exec error/exit handling. The handleFork listener (on the + // pre-exec worker) is gone with the terminated worker; without re-arming + // here, a wasm trap in the exec'd binary leaves waitpid blocked forever. + installProcessWorkerListeners(replacementWorker, pid); + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + pid, + newMemory, + () => { (replacementWorker as DeferredWorkerHandle).start(); }, + () => { void replacementWorker?.terminate(); }, + ); + if (startDisposition === "stale") { + throw new Error(`Exec pid ${pid} changed generation before Worker launch`); + } + if (startDisposition === "dead") { + kernelWorker.finishProcessExecHandoff(pid); + kernelWorker.finalizeExecHandoffTermination(pid); + return 0; + } + kernelWorker.finishProcessExecHandoff(pid); + return 0; + } catch (err) { + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + threadedProcessPids.delete(pid); + try { + kernelWorker.prepareProcessForExec(pid); + } catch { + // Continue with best-effort process death below. + } + if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { + await terminateTrackedWorker(replacementWorker); + } + + const message = err instanceof Error ? err.message : String(err); + try { + reportHostDiagnostic({ + pid, + status: signalExitStatus(SIGSEGV), + source: "exec post-commit transition", + message: `[exec] post-commit transition failed: ${message}`, + }); + } catch { + // A closed host port must not prevent kernel-side reap. + } + try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } + try { + handleExit(pid, signalExitStatus(SIGSEGV), SIGSEGV); + } catch { + try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } + } + return 0; + } } /** * Handle SYS_SPAWN (non-forking posix_spawn) on the browser host. * * The kernel has already constructed the child Process descriptor under - * `childPid` with attrs and file actions applied. This callback resolves - * `path` to bytes via the shared MemoryFileSystem, allocates a fresh - * Memory for the child, registers it with the kernel + * `childPid` with attrs and file actions applied. This callback receives the + * preflight's compiled program, allocates a fresh Memory for the child, and + * registers it with the kernel * (`skipKernelCreate: true` — kernel did its half), and spawns a Worker. * * Distinct from handleExec (which replaces the calling worker) and @@ -1166,9 +1353,9 @@ async function handleExec( /** * Pre-flight resolver — see node-kernel-worker-entry.ts:handlePosixSpawnResolve. * Browser-side equivalent: materialize the lazy file (async fetch via - * the memfs lazy-loader, avoiding sync-XHR + SW deadlocks) then read its - * contents from the VFS. Side-effect-free; safe to call on PATH search - * iterations that may not resolve. + * the memfs lazy-loader, avoiding sync-XHR + SW deadlocks), reads its + * contents from the VFS, follows shebangs, and compiles the final Wasm + * module. Safe to call before the kernel applies spawn file actions. */ async function handlePosixSpawnResolve( path: string, @@ -1178,20 +1365,24 @@ async function handlePosixSpawnResolve( } /** - * Launch a worker for a SYS_SPAWN child whose program bytes have already - * been resolved by `handlePosixSpawnResolve`. Mirrors the Node entry's - * `handlePosixSpawn`. + * Launch a worker for a SYS_SPAWN child whose program has already been + * resolved and compiled by `handlePosixSpawnResolve`. Mirrors the Node + * entry's `handlePosixSpawn`. */ async function handlePosixSpawn( + parentPid: number, childPid: number, - programBytes: ArrayBuffer, - argv: string[], + program: ResolvedSpawnProgram, envp: string[], ): Promise { await waitForProcessTeardowns(); - post({ type: "proc_event", kind: "spawn", pid: childPid }); + // Unrelated teardown waits yield to the event loop. Keep a successfully + // created zombie, but never resurrect it with a new Worker. + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; + post({ type: "proc_event", kind: "spawn", pid: childPid, ppid: parentPid }); + const { programBytes, programModule, argv } = program; const ptrWidth = detectPtrWidth(programBytes); const { memory: newMemory, @@ -1216,8 +1407,9 @@ async function handlePosixSpawn( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: 0, + ppid: parentPid, programBytes, + programModule, memory: newMemory, channelOffset: newChannelOffset, argv, @@ -1226,11 +1418,14 @@ async function handlePosixSpawn( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const newWorker = workerAdapter.createWorker(initData); + const newWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(initData), + ); processes.set(childPid, { memory: newMemory, programBytes, + programModule, worker: newWorker, argv, channelOffset: newChannelOffset, @@ -1241,6 +1436,27 @@ async function handlePosixSpawn( installProcessWorkerListeners(newWorker, childPid); + try { + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + childPid, + newMemory, + () => { newWorker.start(); }, + () => { void newWorker.terminate(); }, + ); + if (startDisposition === "stale") { + throw new Error(`Spawn child ${childPid} changed generation before Worker launch`); + } + } catch (error) { + if (processes.get(childPid)?.worker === newWorker) { + processes.delete(childPid); + threadModuleCache.delete(childPid); + ptyByPid.delete(childPid); + vmInterruptTimers.clear(childPid); + } + void newWorker.terminate(); + throw error; + } + return 0; } @@ -1264,21 +1480,36 @@ async function handleClone( // We keep this separate from processInfo.programModule (which is the unpatched // module used for fork children) to avoid conflating the two. let threadModule = threadModuleCache.get(pid); + let cacheCompiledModule = false; if (!threadModule) { const patched = patchWasmForThread(processInfo.programBytes); threadModule = await WebAssembly.compile(patched); - threadModuleCache.set(pid, threadModule); + cacheCompiledModule = true; + } + + // Compilation yields. A sibling pthread may have committed exec while this + // clone continuation was suspended; never attach the old program/Memory to + // the replacement process that now owns the same numeric pid. + if (!isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ) || !kernelWorker.isProcessExecutionActive(pid)) { + throw new Error(`Process ${pid} changed generation during clone`); } + if (cacheCompiledModule) threadModuleCache.set(pid, threadModule); let alloc: ReturnType; try { alloc = processInfo.threadAllocator.allocate(memory); } catch (e) { const message = e instanceof Error ? e.message : String(e); - post({ - type: "stderr", + reportHostDiagnostic({ pid, - data: new TextEncoder().encode(`[kernel-worker] pid=${pid}: ${message}\n`), + source: "clone allocation", + message: `[kernel-worker] pid=${pid}: ${message}`, }); throw e; } @@ -1286,7 +1517,12 @@ async function handleClone( // Register fnPtr/argPtr so handleFork can route a fork() from this // thread back through its entry point. Mirrors handleClone in // host/src/node-kernel-worker-entry.ts. - kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr); + try { + kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + } catch (err) { + processInfo.threadAllocator.free(alloc.basePage); + throw err; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -1295,6 +1531,7 @@ async function handleClone( programBytes: processInfo.programBytes, programModule: threadModule, memory, + processChannelOffset: processInfo.channelOffset, channelOffset: alloc.channelOffset, fnPtr, argPtr, @@ -1307,7 +1544,9 @@ async function handleClone( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const threadWorker = workerAdapter.createWorker(threadInitData); + const threadWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(threadInitData), + ); if (!threadWorkers.has(pid)) threadWorkers.set(pid, []); const threadEntry: ThreadWorkerInfo = { worker: threadWorker, @@ -1317,17 +1556,23 @@ async function handleClone( }; threadWorkers.get(pid)!.push(threadEntry); + const belongsToCurrentProcessImage = () => + isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ); let reclaimed = false; const reclaimThread = () => { if (reclaimed) return; reclaimed = true; processInfo.threadAllocator.free(alloc.basePage); - threadExits.release(pid, alloc.channelOffset); - const threads = threadWorkers.get(pid); - if (threads) { - const idx = threads.indexOf(threadEntry); - if (idx >= 0) threads.splice(idx, 1); + if (belongsToCurrentProcessImage()) { + threadExits.release(pid, alloc.channelOffset); } + removeThreadWorkerRegistryEntry(threadWorkers, pid, threadEntry); }; const terminateThreadEntry = (): Promise => { if (!threadEntry.termination) { @@ -1340,10 +1585,23 @@ async function handleClone( }; threadExits.register(pid, alloc.channelOffset, terminateThreadEntry); + const isCurrentThreadGeneration = () => + !intentionallyTerminated.has(threadWorker as object) + && belongsToCurrentProcessImage(); const failThread = (reason: string) => { - const text = `[kernel-worker] pid=${pid} tid=${tid}: ${reason}\n`; - post({ type: "stderr", pid, data: new TextEncoder().encode(text) }); + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } const disposition = threadWorkerFailureDisposition(reason); + reportHostDiagnostic({ + pid, + status: disposition.kind === "guest-fatal-trap" + ? disposition.exitStatus + : undefined, + source: "thread worker failure", + message: `[kernel-worker] pid=${pid} tid=${tid}: ${reason}`, + }); kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); void terminateThreadEntry(); if (disposition.kind === "guest-fatal-trap") { @@ -1354,18 +1612,50 @@ async function handleClone( threadWorker.on("message", (msg: unknown) => { const m = msg as WorkerToHostMessage; if (m.type === "thread_exit") { + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } void terminateThreadEntry(); } else if ((m as { type?: string }).type === "error") { // worker-main posted {type:"error"} — instantiation failure, top-level // throw, etc. Without this the parent's pthread_join blocks forever. failThread((m as { message?: string }).message ?? "thread error"); + } else if (m.type === "vm_interrupt_timer") { + if (!isCurrentThreadGeneration() || m.pid !== pid) return; + handleVmInterruptTimer(m, pid, processInfo); } }); threadWorker.on("error", (err: Error) => { - console.error(`[kernel-worker] thread worker error pid=${pid} tid=${tid}:`, err.message); failThread(`worker error: ${err.message ?? err}`); }); + let startDisposition: ReturnType< + CentralizedKernelWorker["startProcessWorkerWhenRunnable"] + >; + try { + startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + pid, + memory, + () => { threadWorker.start(); }, + () => { void threadWorker.terminate(); }, + () => { + kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); + const failedClone = kernelWorker.failDeferredCloneLaunch(pid, tid, 12); + void terminateThreadEntry(); + return failedClone; + }, + ); + } catch (error) { + kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); + void terminateThreadEntry(); + throw error; + } + if (startDisposition === "stale") { + void terminateThreadEntry(); + throw new Error(`Process ${pid} changed generation before thread Worker launch`); + } + return tid; } @@ -1377,18 +1667,27 @@ function signalFromExitStatus(exitStatus: number): number | null { return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null; } -function handleExit(pid: number, exitStatus: number, crashSignum?: number): void { - void finishProcessExit(pid, exitStatus, crashSignum); +function handleExit( + pid: number, + exitStatus: number, + crashSignum?: number, + expectedWorker = processes.get(pid)?.worker, +): void { + void finishProcessExit(pid, exitStatus, crashSignum, expectedWorker); } async function finishProcessExit( pid: number, exitStatus: number, crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV, + expectedWorker = processes.get(pid)?.worker, ): Promise { - if (processTeardowns.has(pid)) return; - + if (!expectedWorker) return; const info = processes.get(pid); + if (!info || info.worker !== expectedWorker) return; + if (processTeardowns.has(expectedWorker)) return; + vmInterruptTimers.clear(pid); + reportNonzeroProcessExitDiagnostic(pid, exitStatus, "kernel process exit"); const threadedSettleMs = threadedProcessPids.has(pid) ? THREADED_WORKER_TERMINATION_SETTLE_MS @@ -1417,9 +1716,11 @@ async function finishProcessExit( // completions, otherwise the worker can park in Atomics.wait with no // registered listener left to wake it. await terminateThreadWorkers(pid); - if (info?.worker) { - await terminateTrackedWorker(info.worker, settleMs); - } + await terminateTrackedWorker(expectedWorker, settleMs); + + // Exec may have installed a replacement while old worker termination was + // settling. Never apply pid-wide cleanup to a different generation. + if (processes.get(pid)?.worker !== expectedWorker) return; // Check if this is a "top-level" process or a fork child. For now, // always deactivate after worker termination; the main thread tracks @@ -1431,50 +1732,105 @@ async function finishProcessExit( threadModuleCache.delete(pid); ptyByPid.delete(pid); })(); - processTeardowns.set(pid, teardown); + processTeardowns.set(expectedWorker, teardown); post({ type: "exit", pid, status: exitStatus }); try { await teardown; } finally { - processTeardowns.delete(pid); + processTeardowns.delete(expectedWorker); } } // ── Terminate ── // Read a file out of the kernel-owned VFS and return its bytes (or null if it -// does not exist / is not readable). This is the main thread's only window -// into the worker-owned FS — used by demos that collect artifacts a process -// wrote (e.g. sqlite-test's result DB/logs) now that the main thread no longer -// shares the VFS SharedArrayBuffer. +// does not exist / is not readable). Used by demos that collect artifacts a +// process wrote (e.g. sqlite-test's result DB/logs) without sharing the live +// VFS SharedArrayBuffer with the main thread. function handleReadVfsFile(msg: Extract) { - if (!memfs) { respond(msg.requestId, null); return; } + if (!io) { respond(msg.requestId, null); return; } try { - const st = memfs.stat(msg.path); + const st = io.stat(msg.path); const size = Number(st.size); - const fd = memfs.open(msg.path, 0 /* O_RDONLY */, 0); + const fd = io.open(msg.path, 0 /* O_RDONLY */, 0); try { const out = new Uint8Array(size); let off = 0; while (off < size) { - const n = memfs.read(fd, out.subarray(off), null, size - off); + const n = io.read(fd, out.subarray(off), null, size - off); if (n <= 0) break; off += n; } // Copy into a plain (non-shared) ArrayBuffer so it structured-clones back. - respond(msg.requestId, out.slice(0, off)); + const data = out.slice(0, off); + respond( + msg.requestId, + msg.includeMode ? { data, mode: st.mode & 0o7777 } : data, + ); } finally { - memfs.close(fd); + io.close(fd); } } catch { respond(msg.requestId, null); } } +// Mutate the mounted filesystem from inside its owning worker. This keeps the +// VFS SAB off the persistent browser main thread while allowing harnesses to +// stage transient files between process spawns. +function handleWriteVfsFile(msg: Extract) { + if (!io) { respondError(msg.requestId, "VFS is not initialized"); return; } + let fd: number | null = null; + try { + fd = io.open(msg.path, 0o1101 /* O_WRONLY|O_CREAT|O_TRUNC */, msg.mode & 0o7777); + let offset = 0; + while (offset < msg.data.byteLength) { + const written = io.write( + fd, + msg.data.subarray(offset), + null, + msg.data.byteLength - offset, + ); + if (written <= 0) { + throw new Error(`Short write while staging ${msg.path}`); + } + offset += written; + } + io.close(fd); + fd = null; + // open(O_CREAT) preserves an existing file's mode. Apply the caller's + // requested mode explicitly so replacement and creation behave alike. + io.chmod(msg.path, msg.mode & 0o7777); + respond(msg.requestId, true); + } catch (err) { + if (fd !== null) { + try { io.close(fd); } catch { /* preserve the original failure */ } + } + respondError(msg.requestId, formatError(err)); + } +} + +function handleUnlinkVfsFile(msg: Extract) { + if (!io) { respondError(msg.requestId, "VFS is not initialized"); return; } + try { + try { + io.lstat(msg.path); + } catch { + respond(msg.requestId, false); + return; + } + io.unlink(msg.path); + respond(msg.requestId, true); + } catch (err) { + respondError(msg.requestId, formatError(err)); + } +} + async function handleTerminateProcess(msg: Extract) { const pid = msg.pid; + vmInterruptTimers.clear(pid); // Terminate thread workers const threads = threadWorkers.get(pid); @@ -1683,6 +2039,7 @@ async function handleDestroy(msg: Extract { case "spawn": void handleSpawn(msg); break; case "terminate_process": void handleTerminateProcess(msg); break; case "read_vfs_file": handleReadVfsFile(msg); break; + case "write_vfs_file": handleWriteVfsFile(msg); break; + case "unlink_vfs_file": handleUnlinkVfsFile(msg); break; case "append_stdin_data": kernelWorker.appendStdinData(msg.pid, msg.data); break; case "set_stdin_data": kernelWorker.setStdinData(msg.pid, msg.data); break; case "pty_write": handlePtyWrite(msg); break; @@ -1965,6 +2324,12 @@ sw.onmessage = (e: MessageEvent) => { kernelWorker.attachKmsStats(msg.crtcId, msg.stats); break; default: { + // Every typed MainToKernelMessage must have a case above. Browser + // tooling also sends a few deliberately out-of-band control messages, + // which remain handled below after the compile-time exhaustiveness + // check. + const exhaustive: never = msg; + void exhaustive; // Handle non-protocol messages (e.g., bridge port transfer) const raw = e.data as any; if (raw?.type === "sysprof_start") { @@ -2017,7 +2382,11 @@ sw.onmessage = (e: MessageEvent) => { }; } } else { - console.warn("[kernel-worker] Unknown message type:", raw?.type); + reportHostDiagnostic({ + pid: 0, + source: "worker protocol", + message: `[kernel-worker] unknown main-thread message type: ${String(raw?.type)}`, + }, "warn"); } } } diff --git a/host/src/browser.ts b/host/src/browser.ts index c6d8597ea6..9b81f88870 100644 --- a/host/src/browser.ts +++ b/host/src/browser.ts @@ -9,8 +9,18 @@ export { SharedPipeBuffer } from "./shared-pipe-buffer"; export { BrowserWorkerAdapter } from "./worker-adapter-browser"; export { centralizedWorkerMain, centralizedThreadWorkerMain, patchWasmForThread } from "./worker-main"; export type { MessagePort as WorkerMessagePort } from "./worker-main"; -export type { KernelConfig, PlatformIO, StatResult, StatfsResult } from "./types"; +export type { + KernelConfig, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "./types"; +export { PATHCONF_NAMES } from "./generated/abi"; +export { filesystemPathconf } from "./pathconf"; +export type { PathconfProfile } from "./pathconf"; export type { WorkerAdapter, WorkerHandle } from "./worker-adapter"; +export type { HostDiagnostic } from "./host-diagnostic"; export type { HostToWorkerMessage, WorkerToHostMessage, WorkerReadyMessage, WorkerExitMessage, WorkerErrorMessage, diff --git a/host/src/deferred-worker-handle.ts b/host/src/deferred-worker-handle.ts new file mode 100644 index 0000000000..46c121fa66 --- /dev/null +++ b/host/src/deferred-worker-handle.ts @@ -0,0 +1,89 @@ +import type { WorkerHandle } from "./worker-adapter"; + +/** + * A WorkerHandle whose backing Worker is constructed only when `start()` is + * called. Process setup can therefore publish memory, channels, and host + * metadata while a job-control-stopped process remains unable to execute a + * single guest instruction. Listeners are installed on the eventual Worker + * as one generation, and `terminate()` before `start()` permanently cancels + * the launch. + */ +export class DeferredWorkerHandle implements WorkerHandle { + private worker: WorkerHandle | null = null; + private terminated = false; + private terminationPromise: Promise | null = null; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + private readonly handlers = new Map void>>(); + private readonly pendingMessages: Array<{ + message: unknown; + transfer?: Transferable[]; + }> = []; + + constructor(private readonly create: () => WorkerHandle) {} + + /** Construct the backing Worker exactly once. Returns false after cancel. */ + start(): boolean { + if (this.terminated) return false; + if (this.worker) return true; + + let worker: WorkerHandle; + try { + worker = this.create(); + } catch (error) { + this.terminated = true; + this.pendingMessages.splice(0); + // Construction is part of fork/spawn/exec/clone setup. Propagate a + // synchronous failure to that operation's existing rollback/error path; + // emitting an error event here would race the same failure through host + // teardown while callers incorrectly continue as if launch succeeded. + throw error; + } + + this.worker = worker; + for (const [event, handlers] of this.handlers) { + for (const handler of handlers) worker.on(event as any, handler as any); + } + for (const { message, transfer } of this.pendingMessages.splice(0)) { + worker.postMessage(message, transfer); + } + return true; + } + + postMessage(message: unknown, transfer?: Transferable[]): void { + if (this.terminated) return; + if (this.worker) { + this.worker.postMessage(message, transfer); + return; + } + this.pendingMessages.push({ message, transfer }); + } + + on(event: "message", handler: (message: unknown) => void): void; + on(event: "error", handler: (error: Error) => void): void; + on(event: "exit", handler: (code: number) => void): void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + on(event: string, handler: (...args: any[]) => void): void { + let handlers = this.handlers.get(event); + if (!handlers) { + handlers = new Set(); + this.handlers.set(event, handlers); + } + handlers.add(handler); + if (this.worker) this.worker.on(event as any, handler as any); + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + off(event: string, handler: (...args: any[]) => void): void { + this.handlers.get(event)?.delete(handler); + if (this.worker) this.worker.off(event, handler); + } + + terminate(): Promise { + if (this.terminationPromise) return this.terminationPromise; + if (this.terminated) return Promise.resolve(0); + this.terminated = true; + this.pendingMessages.splice(0); + this.terminationPromise = this.worker?.terminate() ?? Promise.resolve(0); + return this.terminationPromise; + } +} diff --git a/host/src/dylink.ts b/host/src/dylink.ts index f159d2b8e6..b08a1a8a4c 100644 --- a/host/src/dylink.ts +++ b/host/src/dylink.ts @@ -6,6 +6,9 @@ * https://github.com/WebAssembly/tool-conventions/blob/main/DynamicLinking.md */ +import { ABI_VERSION } from "./generated/abi"; +import { FORK_SAVE_BUFFER_SIZE } from "./process-memory"; + // dylink.0 sub-section types const WASM_DYLINK_MEM_INFO = 1; const WASM_DYLINK_NEEDED = 2; @@ -16,6 +19,77 @@ const WASM_DYLINK_IMPORT_INFO = 4; const WASM_DYLINK_FLAG_TLS = 0x01; const WASM_DYLINK_FLAG_WEAK = 0x02; +export const SIDE_MODULE_FORK_EXPORTS = [ + "wpk_fork_unwind_begin", + "wpk_fork_unwind_end", + "wpk_fork_rewind_begin", + "wpk_fork_rewind_end", + "wpk_fork_state", +] as const; + +export const FORK_CAPABILITIES_SECTION = "kandelo.wpk_fork.capabilities"; +export const FORK_CAPABILITIES_VERSION = 1; +export const FORK_CAP_SIDE_ENTRY = 1 << 0; +export const FORK_CAP_DYLINK_MAIN = 1 << 1; +const FORK_CAP_KNOWN_MASK = FORK_CAP_SIDE_ENTRY | FORK_CAP_DYLINK_MAIN; +export const FORK_CAPABILITIES_REQUIRED_ABI = 17; + +const WPK_FORK_NORMAL = 0; +const WPK_FORK_UNWINDING = 1; +const WPK_FORK_REWINDING = 2; + +export interface ForkInstrumentCapabilityClaim { + /** False for an ABI-16 artifact built before role markers were introduced. */ + present: boolean; + flags: number; +} + +/** Read and validate the explicit call-graph claims emitted by the tool. */ +export function readForkInstrumentCapabilityClaim( + module: WebAssembly.Module, +): ForkInstrumentCapabilityClaim { + const sections = WebAssembly.Module.customSections(module, FORK_CAPABILITIES_SECTION); + if (sections.length === 0) return { present: false, flags: 0 }; + if (sections.length !== 1) { + throw new Error(`duplicate ${FORK_CAPABILITIES_SECTION} custom sections`); + } + const data = new Uint8Array(sections[0]!); + if (data.length !== 2) { + throw new Error(`malformed ${FORK_CAPABILITIES_SECTION} custom section`); + } + if (data[0] !== FORK_CAPABILITIES_VERSION) { + throw new Error( + `unsupported fork-instrument capability version ${data[0]}; ` + + `expected ${FORK_CAPABILITIES_VERSION}`, + ); + } + if ((data[1]! & ~FORK_CAP_KNOWN_MASK) !== 0) { + throw new Error(`unknown fork-instrument capability flags 0x${data[1]!.toString(16)}`); + } + return { present: true, flags: data[1]! }; +} + +/** Return just the validated flags for callers that do not need presence. */ +export function readForkInstrumentCapabilities(module: WebAssembly.Module): number { + return readForkInstrumentCapabilityClaim(module).flags; +} + +/** + * Decide whether an artifact may serve one fork-instrument role. + * + * ABI 16 predates role markers, so an absent section falls back to the legacy + * five-export contract. ABI 17 makes the role claim mandatory. A marker that + * is present is always authoritative, including during ABI 16 migration. + */ +export function forkInstrumentRoleAvailable( + claim: ForkInstrumentCapabilityClaim, + roleFlag: number, + abiVersion: number = ABI_VERSION, +): boolean { + if (claim.present) return (claim.flags & roleFlag) !== 0; + return abiVersion < FORK_CAPABILITIES_REQUIRED_ABI; +} + export interface DylinkMetadata { /** Bytes of linear memory this module needs */ memorySize: number; @@ -149,6 +223,44 @@ export function parseDylinkSection(wasmBytes: Uint8Array): DylinkMetadata | null return metadata; } +/** + * Return function exports whose indices refer to module-defined functions. + * + * WebAssembly.Module.exports() exposes only names and kinds. Dynamic-linker + * self-import handling also needs the function index: an imported function can + * be re-exported under the same name, but that is not a local definition and + * must not receive a trampoline back to itself. + * + * `module` has already validated the binary before this helper is called, so + * section bounds and LEB encodings are known to be structurally valid. + */ +function readDefinedFunctionExports( + wasmBytes: Uint8Array, + importedFunctionCount: number, +): Set { + const result = new Set(); + const offset = { value: 8 }; + while (offset.value < wasmBytes.length) { + const sectionId = wasmBytes[offset.value++]; + const sectionSize = readVarUint(wasmBytes, offset); + const sectionEnd = offset.value + sectionSize; + if (sectionId !== 7) { + offset.value = sectionEnd; + continue; + } + + const exportCount = readVarUint(wasmBytes, offset); + for (let i = 0; i < exportCount; i++) { + const name = readString(wasmBytes, offset); + const kind = wasmBytes[offset.value++]; + const index = readVarUint(wasmBytes, offset); + if (kind === 0 && index >= importedFunctionCount) result.add(name); + } + break; + } + return result; +} + /** Align a value up to the given alignment (must be power of 2). */ function alignUp(value: number, align: number): number { return (value + align - 1) & ~(align - 1); @@ -170,18 +282,51 @@ export interface LoadedSharedLibrary { metadata: DylinkMetadata; /** Path/name of the library */ name: string; + /** Fork save buffer for an instrumented side module importing env.fork. */ + forkBufAddr?: number; + /** Thread-local-storage base captured from the parent instance. */ + tlsBase?: number; + /** Whether this module can originate a coordinated env.fork unwind. */ + forkCapable: boolean; + /** Function/GOT.func imports used for conservative cross-side isolation. */ + functionImports: ReadonlySet; + /** Function exports visible to later side modules. */ + functionExports: ReadonlySet; + /** Dynamic lookup from a side module defeats static cross-side isolation. */ + importsDynamicLookup: boolean; +} + +export interface SideModuleForkState { + name: string; + instance: WebAssembly.Instance; + forkBufAddr: number; + /** Byte capacity reserved for this module's continuation frames. */ + forkBufSize: number; +} + +/** + * Process-worker coordination for the one supported side-module fork shape: + * a main-module call_indirect directly invokes one instrumented side module. + * The loader rejects statically visible side-to-side linkage and side-owned + * dlopen/dlsym around a fork-capable module. Opaque callbacks passed through + * main memory or the shared table cannot yet be attributed to a module at + * runtime and remain an explicit unsupported residual. + */ +export interface SideModuleForkSupport { + setActiveFork: (state: SideModuleForkState) => void; + clearActiveFork: (state: SideModuleForkState) => void; + /** Invoke the immutable main-module fork trampoline and verify its state. */ + invokeMainFork: (expectedStateAfter: 0 | 1) => number; } /** * Options used when re-instantiating a side module in a fork child. * * Preconditions: - * - Replay must run in the same order as the parent's original dlopens, - * against the same initial table/memory state. `__table_base` is - * captured from `options.table.length` at replay time and must equal - * the parent's value — anything that grows the child's table before - * replay (a future GOT-prealloc pass, an interleaved dlsym, etc.) - * diverges the layout and corrupts call_indirect targets. + * - Replay must run in the same order as the parent's original dlopens. + * Each entry supplies the parent's exact `__table_base`; replay may pad + * null gaps up to that base but rejects a child table that already grew + * past it (an interleaved dlsym, future GOT preallocation, etc.). * - `options.loadedLibraries` must NOT already contain `name`. Replay * does not refresh existing entries; a duplicate would be silently * deduped and return a handle whose memoryBase may not match. @@ -193,6 +338,15 @@ export interface DylinkReplayOptions { * the memcpy'd data section encode (memoryBase + offset); using any * other base corrupts pointers. */ memoryBase: number; + /** Exact table base observed in the parent, including failed-load gaps. */ + tableBase: number; + /** Exact side-module save buffer copied from the fork parent. */ + forkBufAddr?: number; + /** Exact mutable `__tls_base` value from the fork parent. The child memory + * already contains the parent's live TLS bytes, so replay restores only + * this instance-local global and deliberately does not call + * `__wasm_init_tls`, which would reset those bytes to the initial image. */ + tlsBase?: number; } /** @@ -209,18 +363,188 @@ export interface LoadSharedLibraryOptions { heapPointer?: { value: number }; /** Allocate side-module linear-memory data in the process address space */ allocateMemory?: (size: number, align: number) => number; + /** Release a successful allocateMemory result when loading rolls back. */ + deallocateMemory?: (addr: number, size: number) => void; /** Global symbol table: name → function or WebAssembly.Global */ globalSymbols: Map; /** GOT entries: symbol name → mutable i32 WebAssembly.Global */ got: Map; /** Already-loaded libraries for dedup and dependency resolution */ loadedLibraries: Map; + /** + * Process-owned exception tag shared by the main image and every side + * module. When omitted, standalone linker users get one tag lazily and it + * is retained on this options object for subsequent loads. + */ + longjmpTag?: WebAssembly.Tag; + /** + * Process-owned C++ exception tag shared by every C++ side module. C++ + * exceptions crossing side-module calls require tag identity as well as a + * matching payload type, so this must not be allocated per dlopen. + */ + cppExceptionTag?: WebAssembly.Tag; + /** Process pointer width, which also determines the __c_longjmp payload. */ + ptrWidth?: 4 | 8; + /** Immutable symbol names exported by the main module. */ + mainModuleSymbols?: ReadonlySet; + /** Present only in a process worker that can drive side-module unwind. */ + sideModuleFork?: SideModuleForkSupport; + /** Precise rebuild/boundary diagnostic when sideModuleFork is unavailable. */ + sideModuleForkUnavailableReason?: string; /** Callback to locate and read a library file by name (async version) */ resolveLibrary?: (name: string) => Promise; /** Callback to locate and read a library file by name (sync version) */ resolveLibrarySync?: (name: string) => Uint8Array | null; } +type TagConstructor = new ( + descriptor: { parameters: Array<"i32" | "i64"> }, +) => WebAssembly.Tag; + +function tagConstructor(): TagConstructor | undefined { + return (WebAssembly as typeof WebAssembly & { Tag?: TagConstructor }).Tag; +} + +/** Create the exception tag used by one process and all of its side modules. */ +export function createLongjmpTag(ptrWidth: 4 | 8): WebAssembly.Tag | undefined { + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new TypeError(`invalid process pointer width ${String(ptrWidth)}`); + } + const Tag = tagConstructor(); + return Tag + ? new Tag({ parameters: [ptrWidth === 8 ? "i64" : "i32"] }) + : undefined; +} + +/** Create the process-owned C++ exception tag for the target pointer width. */ +export function createCppExceptionTag(ptrWidth: 4 | 8): WebAssembly.Tag | undefined { + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new TypeError(`invalid process pointer width ${String(ptrWidth)}`); + } + const Tag = tagConstructor(); + return Tag + ? new Tag({ parameters: [ptrWidth === 8 ? "i64" : "i32"] }) + : undefined; +} + +/** Reject lookalike values before handing an exception-tag import to Wasm. */ +export function requireLongjmpTag(value: unknown, context: string): WebAssembly.Tag { + const Tag = tagConstructor(); + if (!Tag) { + throw new Error(`${context}: this WebAssembly runtime does not support exception tags`); + } + if (!(value instanceof Tag)) { + throw new TypeError(`${context}: __c_longjmp must be an actual WebAssembly.Tag`); + } + return value; +} + +/** Reject lookalike values before handing the C++ tag import to Wasm. */ +export function requireCppExceptionTag(value: unknown, context: string): WebAssembly.Tag { + const Tag = tagConstructor(); + if (!Tag) { + throw new Error(`${context}: this WebAssembly runtime does not support exception tags`); + } + if (!(value instanceof Tag)) { + throw new TypeError(`${context}: __cpp_exception must be an actual WebAssembly.Tag`); + } + return value; +} + +function validateLongjmpConfiguration(options: LoadSharedLibraryOptions): void { + const ptrWidth = options.ptrWidth ?? 4; + if (ptrWidth !== 4 && ptrWidth !== 8) { + throw new TypeError(`invalid process pointer width ${String(ptrWidth)}`); + } + if (options.longjmpTag !== undefined) { + requireLongjmpTag(options.longjmpTag, "dynamic linker"); + } +} + +function resolveLongjmpTag(options: LoadSharedLibraryOptions): WebAssembly.Tag { + validateLongjmpConfiguration(options); + if (options.longjmpTag !== undefined) return options.longjmpTag; + const ptrWidth = options.ptrWidth ?? 4; + const tag = createLongjmpTag(ptrWidth); + options.longjmpTag = requireLongjmpTag(tag, "dynamic linker"); + return options.longjmpTag; +} + +function resolveCppExceptionTag(options: LoadSharedLibraryOptions): WebAssembly.Tag { + validateLongjmpConfiguration(options); + if (options.cppExceptionTag !== undefined) { + return requireCppExceptionTag(options.cppExceptionTag, "dynamic linker"); + } + const ptrWidth = options.ptrWidth ?? 4; + const tag = createCppExceptionTag(ptrWidth); + options.cppExceptionTag = requireCppExceptionTag(tag, "dynamic linker"); + return options.cppExceptionTag; +} + +const SIDE_DYNAMIC_LOOKUP_IMPORTS = new Set([ + "__wasm_dlopen", + "__wasm_dlsym", + "dlopen", + "dlsym", +]); + +function intersectSideSymbols( + imports: ReadonlySet, + exports: ReadonlySet, + mainSymbols: ReadonlySet, +): string[] { + return Array.from(imports) + .filter((name) => !mainSymbols.has(name) && exports.has(name)) + .sort(); +} + +/** + * The current two-module unwind protocol supports main -> one side module. + * It cannot serialize an intervening side-module frame. Preserve ordinary + * independent multi-extension loading, but reject statically visible + * side-to-side linkage and side-originated dynamic lookup whenever either + * participant can fork. Function pointers passed opaquely through main memory + * remain a documented residual until the runtime has module activation hooks. + */ +function enforceDirectMainSideForkBoundary( + name: string, + forkCapable: boolean, + functionImports: ReadonlySet, + functionExports: ReadonlySet, + importsDynamicLookup: boolean, + options: LoadSharedLibraryOptions, +): void { + const mainSymbols = options.mainModuleSymbols ?? new Set(); + for (const loaded of options.loadedLibraries.values()) { + if (!forkCapable && !loaded.forkCapable) continue; + + if (importsDynamicLookup || loaded.importsDynamicLookup) { + throw new Error( + `${name}: fork-capable side modules cannot coexist with side-originated ` + + `dlopen/dlsym; only a direct main-module-to-side fork path is supported`, + ); + } + + const newToLoaded = intersectSideSymbols( + functionImports, + loaded.functionExports, + mainSymbols, + ); + const loadedToNew = intersectSideSymbols( + loaded.functionImports, + functionExports, + mainSymbols, + ); + const crossSymbols = [...newToLoaded, ...loadedToNew]; + if (crossSymbols.length > 0) { + throw new Error( + `${name}: fork-capable side-module nesting through ${loaded.name} is unsupported ` + + `(cross-side symbols: ${Array.from(new Set(crossSymbols)).join(", ")})`, + ); + } + } +} + /** * Core shared library loading logic — instantiates a pre-parsed Wasm * side module into the process address space. Used by both async and sync @@ -233,225 +557,589 @@ function instantiateSharedLibrary( options: LoadSharedLibraryOptions, replay?: DylinkReplayOptions, ): LoadedSharedLibrary { - // Allocate memory region - const memAlign = 1 << metadata.memoryAlign; - let memoryBase = 0; - if (metadata.memorySize > 0) { + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + const moduleImports = WebAssembly.Module.imports(module); + const moduleExports = WebAssembly.Module.exports(module); + const importsFork = moduleImports.some((imp) => + imp.module === "env" && imp.name === "fork" && imp.kind === "function" + ); + const presentForkExports = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => + moduleExports.some((exp) => exp.kind === "function" && exp.name === exportName) + ); + const hasCompleteForkInstrumentation = + presentForkExports.length === SIDE_MODULE_FORK_EXPORTS.length; + const forkCapabilityClaim = readForkInstrumentCapabilityClaim(module); + const claimsSideEntry = + forkCapabilityClaim.present + && (forkCapabilityClaim.flags & FORK_CAP_SIDE_ENTRY) !== 0; + const sideEntryAvailable = forkInstrumentRoleAvailable( + forkCapabilityClaim, + FORK_CAP_SIDE_ENTRY, + ); + const functionImports = new Set( + moduleImports + .filter((imp) => + (imp.module === "env" && imp.kind === "function") + || imp.module === "GOT.func" + ) + .map((imp) => imp.name), + ); + const functionExports = new Set( + moduleExports.filter((exp) => exp.kind === "function").map((exp) => exp.name), + ); + const importedFunctionCount = moduleImports.filter((imp) => imp.kind === "function").length; + const definedFunctionExports = readDefinedFunctionExports( + wasmBytes, + importedFunctionCount, + ); + // wasm-ld can make an interposable C++ definition both an env import and a + // module export. The main process still wins when it supplies the symbol; + // otherwise route only this genuine self-definition back to the module. + // Do not manufacture trampolines for arbitrary unresolved imports: those + // remain instantiation errors instead of turning an ABI gap into a delayed + // failure on a possibly-unexecuted path. + const selfFunctionImports = new Set( + moduleImports + .filter((imp) => + imp.module === "env" + && imp.kind === "function" + && definedFunctionExports.has(imp.name) + ) + .map((imp) => imp.name), + ); + const importsDynamicLookup = moduleImports.some((imp) => + imp.module === "env" + && imp.kind === "function" + && SIDE_DYNAMIC_LOOKUP_IMPORTS.has(imp.name) + ); + const importsLongjmpTag = moduleImports.some((imp) => + imp.module === "env" + && imp.name === "__c_longjmp" + && (imp.kind as string) === "tag" + ); + const longjmpTag = importsLongjmpTag ? resolveLongjmpTag(options) : undefined; + const importsCppExceptionTag = moduleImports.some((imp) => + imp.module === "env" + && imp.name === "__cpp_exception" + && (imp.kind as string) === "tag" + ); + const cppExceptionTag = importsCppExceptionTag + ? resolveCppExceptionTag(options) + : undefined; + + if (presentForkExports.length > 0 && !hasCompleteForkInstrumentation) { + const missing = SIDE_MODULE_FORK_EXPORTS.filter((exportName) => + !moduleExports.some((exp) => exp.kind === "function" && exp.name === exportName) + ); + throw new Error( + `${name}: incomplete wasm-fork-instrument exports; missing ${missing.join(", ")}`, + ); + } + if (importsFork && !hasCompleteForkInstrumentation) { + throw new Error( + `${name}: env.fork requires complete side-module instrumentation; ` + + "rebuild with wasm-fork-instrument --entry env.fork", + ); + } + if (importsFork && !sideEntryAvailable) { + throw new Error( + `${name}: env.fork requires the versioned side-entry capability; ` + + "rebuild with the current wasm-fork-instrument --entry env.fork", + ); + } + if (claimsSideEntry && !importsFork) { + throw new Error(`${name}: side-entry capability is present without an env.fork import`); + } + if (importsFork && !options.sideModuleFork) { + throw new Error( + `${name}: env.fork cannot be coordinated: ` + + (options.sideModuleForkUnavailableReason + ?? "side-module fork requires a process-worker unwind coordinator"), + ); + } + enforceDirectMainSideForkBoundary( + name, + importsFork, + functionImports, + functionExports, + importsDynamicLookup, + options, + ); + + const tableRollbackBase = options.table.length; + const heapRollbackValue = options.heapPointer?.value; + const symbolRollback = new Map(options.globalSymbols); + const gotRollback = new Map( + Array.from(options.got, ([symbol, global]) => [ + symbol, + { global, value: global.value }, + ] as const), + ); + const allocations: Array<{ addr: number; size: number }> = []; + const allocate = (size: number, align: number): number => { + if (!options.allocateMemory) { + throw new Error(`${name}: no side-module memory allocator configured`); + } + const addr = options.allocateMemory(size, align); + allocations.push({ addr, size }); + return addr; + }; + + try { + // Allocate memory region + const memAlign = 1 << metadata.memoryAlign; + let memoryBase = 0; + if (metadata.memorySize > 0) { + if (replay) { + // Reuse parent's memoryBase: data-reloc'd pointers baked into the + // memcpy'd data section already encode (parentMemoryBase + offset). + memoryBase = replay.memoryBase; + } else if (options.allocateMemory) { + memoryBase = allocate(metadata.memorySize, memAlign); + const end = memoryBase + metadata.memorySize; + if (end > options.memory.buffer.byteLength) { + throw new Error( + `${name}: allocator returned 0x${memoryBase.toString(16)} but memory only covers 0x${options.memory.buffer.byteLength.toString(16)}`, + ); + } + } else { + if (!options.heapPointer) { + throw new Error(`${name}: no side-module memory allocator configured`); + } + memoryBase = alignUp(options.heapPointer.value, memAlign); + options.heapPointer.value = memoryBase + metadata.memorySize; + + // Ensure the memory is large enough for standalone linker tests and + // non-POSIX embedders. Process workers pass allocateMemory so side-module + // data is tracked by the guest allocator instead of a host-only pointer. + const neededPages = Math.ceil(options.heapPointer.value / 65536); + const currentPages = options.memory.buffer.byteLength / 65536; + if (neededPages > currentPages) { + options.memory.grow(neededPages - currentPages); + } + } + + if (!replay) { + // Skip zero-init in replay: child memory already holds parent's + // post-startup data via fork memcpy. + new Uint8Array(options.memory.buffer, memoryBase, metadata.memorySize).fill(0); + } + } + + // Reproduce the parent's exact table base, including null gaps left by a + // failed dlopen. WebAssembly.Table cannot shrink, so successful archive + // entries carry the next library's exact base and replay pads up to it. + let tableBase = options.table.length; if (replay) { - // Reuse parent's memoryBase: data-reloc'd pointers baked into the - // memcpy'd data section already encode (parentMemoryBase + offset). - memoryBase = replay.memoryBase; - } else if (options.allocateMemory) { - memoryBase = options.allocateMemory(metadata.memorySize, memAlign); - const end = memoryBase + metadata.memorySize; - if (end > options.memory.buffer.byteLength) { + if (!Number.isSafeInteger(replay.tableBase) || replay.tableBase < 0) { + throw new Error(`${name}: invalid replay table base ${replay.tableBase}`); + } + if (tableBase > replay.tableBase) { throw new Error( - `${name}: allocator returned 0x${memoryBase.toString(16)} but memory only covers 0x${options.memory.buffer.byteLength.toString(16)}`, + `${name}: replay table already at ${tableBase}, past parent base ${replay.tableBase}`, ); } - } else { - if (!options.heapPointer) { - throw new Error(`${name}: no side-module memory allocator configured`); - } - memoryBase = alignUp(options.heapPointer.value, memAlign); - options.heapPointer.value = memoryBase + metadata.memorySize; - - // Ensure the memory is large enough for standalone linker tests and - // non-POSIX embedders. Process workers pass allocateMemory so side-module - // data is tracked by the guest allocator instead of a host-only pointer. - const neededPages = Math.ceil(options.heapPointer.value / 65536); - const currentPages = options.memory.buffer.byteLength / 65536; - if (neededPages > currentPages) { - options.memory.grow(neededPages - currentPages); + if (tableBase < replay.tableBase) { + options.table.grow(replay.tableBase - tableBase); } + tableBase = replay.tableBase; } + if (metadata.tableSize > 0) options.table.grow(metadata.tableSize); - if (!replay) { - // Skip zero-init in replay: child memory already holds parent's - // post-startup data via fork memcpy. - new Uint8Array(options.memory.buffer, memoryBase, metadata.memorySize).fill(0); + let sideForkBufAddr = 0; + if (importsFork) { + if (replay) { + sideForkBufAddr = replay.forkBufAddr ?? 0; + } else if (options.allocateMemory) { + sideForkBufAddr = allocate(FORK_SAVE_BUFFER_SIZE, 16); + } else if (options.heapPointer) { + sideForkBufAddr = alignUp(options.heapPointer.value, 16); + options.heapPointer.value = sideForkBufAddr + FORK_SAVE_BUFFER_SIZE; + const neededPages = Math.ceil(options.heapPointer.value / 65536); + const currentPages = options.memory.buffer.byteLength / 65536; + if (neededPages > currentPages) options.memory.grow(neededPages - currentPages); + } + if ( + sideForkBufAddr <= 0 + || sideForkBufAddr + FORK_SAVE_BUFFER_SIZE > options.memory.buffer.byteLength + ) { + throw new Error(`${name}: invalid side-module fork save buffer`); + } } - } - // Allocate table slots - let tableBase = 0; - if (metadata.tableSize > 0) { - tableBase = options.table.length; - options.table.grow(metadata.tableSize); - } + // Create immutable globals for memory_base and table_base + const memoryBaseGlobal = new WebAssembly.Global( + { value: "i32", mutable: false }, + memoryBase, + ); + const tableBaseGlobal = new WebAssembly.Global( + { value: "i32", mutable: false }, + tableBase, + ); - // Create immutable globals for memory_base and table_base - const memoryBaseGlobal = new WebAssembly.Global( - { value: "i32", mutable: false }, - memoryBase, - ); - const tableBaseGlobal = new WebAssembly.Global( - { value: "i32", mutable: false }, - tableBase, - ); + // Build GOT proxy for imports. + // + // GOT.mem entries hold the *address in linear memory* of a data symbol the + // side module imports from the main process. If the main module exports + // that symbol as a WebAssembly.Global (typical for `--export-all`), its + // value is the address. Without this seeding, side modules read 0 for + // any imported global — silent NULL deref (e.g. opcache.so reads + // `sapi_module.name` as NULL, accel_find_sapi fails at startup). + // + // GOT.func entries hold a *table index* — the address-of-function value + // a C function pointer stores. Side-module data sections capture function + // pointers (e.g. opcache.so's ini_entries[].on_modify == &OnUpdateString + // exported from main). For those references to dispatch to the real + // function at runtime, the function must live in the shared + // indirect_function_table and the GOT entry must hold its index. + const tableIndexFor = (fn: Function): number => { + const tbl = options.table; + for (let i = 0; i < tbl.length; i++) { + if (tbl.get(i) === fn) return i; + } + const idx = tbl.length; + tbl.grow(1); + tbl.set(idx, fn); + return idx; + }; - // Build GOT proxy for imports. - // - // GOT.mem entries hold the *address in linear memory* of a data symbol the - // side module imports from the main process. If the main module exports - // that symbol as a WebAssembly.Global (typical for `--export-all`), its - // value is the address. Without this seeding, side modules read 0 for - // any imported global — silent NULL deref (e.g. opcache.so reads - // `sapi_module.name` as NULL, accel_find_sapi fails at startup). - // - // GOT.func entries hold a *table index* — the address-of-function value - // a C function pointer stores. Side-module data sections capture function - // pointers (e.g. opcache.so's ini_entries[].on_modify == &OnUpdateString - // exported from main). For those references to dispatch to the real - // function at runtime, the function must live in the shared - // indirect_function_table and the GOT entry must hold its index. - const tableIndexFor = (fn: Function): number => { - const tbl = options.table; - for (let i = 0; i < tbl.length; i++) { - if (tbl.get(i) === fn) return i; - } - const idx = tbl.length; - tbl.grow(1); - tbl.set(idx, fn); - return idx; - }; + const getOrCreateGOTEntry = ( + symName: string, + kind: "mem" | "func", + ): WebAssembly.Global => { + let entry = options.got.get(symName); + if (!entry) { + let initial = 0; + const sym = options.globalSymbols.get(symName); + if (kind === "mem" && sym instanceof WebAssembly.Global) { + initial = sym.value as number; + } else if (kind === "func" && typeof sym === "function") { + initial = tableIndexFor(sym); + } + entry = new WebAssembly.Global({ value: "i32", mutable: true }, initial); + options.got.set(symName, entry); + } + return entry; + }; - const getOrCreateGOTEntry = ( - symName: string, - kind: "mem" | "func", - ): WebAssembly.Global => { - let entry = options.got.get(symName); - if (!entry) { - let initial = 0; - const sym = options.globalSymbols.get(symName); - if (kind === "mem" && sym instanceof WebAssembly.Global) { - initial = sym.value as number; - } else if (kind === "func" && typeof sym === "function") { - initial = tableIndexFor(sym); - } - entry = new WebAssembly.Global({ value: "i32", mutable: true }, initial); - options.got.set(symName, entry); - } - return entry; - }; + let instance: WebAssembly.Instance | null = null; + let sideForkState: SideModuleForkState | null = null; + const forkState = (): number => { + if (!instance) throw new Error(`${name}: side-module fork before instantiation`); + return Number((instance.exports.wpk_fork_state as () => number)()); + }; - // Tag imported by side modules compiled with clang's wasm SjLj lowering - // (`-mllvm -wasm-enable-sjlj`). The host doesn't actually catch these — the - // main process either has its own __c_longjmp tag (LLVM 22) or doesn't use - // SjLj (LLVM 21). A stub Tag lets the side module's import type-check and - // instantiate; behavior at throw time is undefined but the side module - // typically never throws this tag itself. - const longjmpTag = (typeof (WebAssembly as any).Tag === "function") - ? new (WebAssembly as any).Tag({ parameters: ["i32"] }) - : undefined; + const sideModuleForkImport = (): number => { + if (!instance || !options.sideModuleFork || sideForkBufAddr === 0) { + throw new Error(`${name}: side-module fork coordinator is unavailable`); + } + const state = forkState(); + if (state === WPK_FORK_NORMAL) { + (instance.exports.wpk_fork_unwind_begin as (addr: number) => void)(sideForkBufAddr); + if (forkState() !== WPK_FORK_UNWINDING) { + throw new Error(`${name}: side-module fork failed to enter UNWINDING`); + } + sideForkState = { + name, + instance, + forkBufAddr: sideForkBufAddr, + forkBufSize: FORK_SAVE_BUFFER_SIZE, + }; + options.sideModuleFork.setActiveFork(sideForkState); + return options.sideModuleFork.invokeMainFork(WPK_FORK_UNWINDING); + } - // Construct imports - const imports: WebAssembly.Imports = { - env: new Proxy({} as Record, { - get(_target, prop: string) { - switch (prop) { - case "memory": return options.memory; - case "__indirect_function_table": return options.table; - case "__memory_base": return memoryBaseGlobal; - case "__table_base": return tableBaseGlobal; - case "__stack_pointer": return options.stackPointer; - case "__c_longjmp": return longjmpTag; + if (state === WPK_FORK_REWINDING) { + (instance.exports.wpk_fork_rewind_end as () => void)(); + if (forkState() !== WPK_FORK_NORMAL) { + throw new Error(`${name}: side-module fork failed to finish REWINDING`); } - const sym = options.globalSymbols.get(prop); - if (sym !== undefined) return sym; - return undefined; - }, - has(_target, prop: string) { - if (["memory", "__indirect_function_table", "__memory_base", - "__table_base", "__stack_pointer", "__c_longjmp"].includes(prop)) return true; - return options.globalSymbols.has(prop); - }, - }), - "GOT.mem": new Proxy({} as Record, { - get(_target, prop: string) { - return getOrCreateGOTEntry(prop, "mem"); - }, - }), - "GOT.func": new Proxy({} as Record, { - get(_target, prop: string) { - return getOrCreateGOTEntry(prop, "func"); - }, - }), - }; + // A fork child re-instantiates this module, so its closure cannot retain + // the parent's SideModuleForkState object. The worker reconstructs the + // active identity from the copied archive/buffer metadata; rebuild the + // same structural identity here before clearing it. + const completedState = sideForkState ?? { + name, + instance, + forkBufAddr: sideForkBufAddr, + forkBufSize: FORK_SAVE_BUFFER_SIZE, + }; + const result = options.sideModuleFork.invokeMainFork(WPK_FORK_NORMAL); + options.sideModuleFork.clearActiveFork(completedState); + sideForkState = null; + return result; + } - // Compile and instantiate synchronously - const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); - const instance = new WebAssembly.Instance(module, imports); + throw new Error(`${name}: env.fork reached in unexpected state ${state}`); + }; + + // Construct imports + const imports: WebAssembly.Imports = { + env: new Proxy({} as Record, { + get(_target, prop: string) { + switch (prop) { + case "memory": return options.memory; + case "__indirect_function_table": return options.table; + case "__memory_base": return memoryBaseGlobal; + case "__table_base": return tableBaseGlobal; + case "__stack_pointer": return options.stackPointer; + case "__c_longjmp": return longjmpTag; + case "__cpp_exception": return cppExceptionTag; + case "fork": + if (importsFork) return sideModuleForkImport; + break; + } + const sym = options.globalSymbols.get(prop); + if (sym !== undefined) return sym; + if (selfFunctionImports.has(prop)) { + return (...args: unknown[]) => { + const fn = instance?.exports[prop]; + if (typeof fn !== "function") { + throw new Error(`${name}: self import env.${prop} is unavailable`); + } + return (fn as Function)(...args); + }; + } + return undefined; + }, + has(_target, prop: string) { + if (["memory", "__indirect_function_table", "__memory_base", + "__table_base", "__stack_pointer", "__c_longjmp", + "__cpp_exception"].includes(prop)) return true; + if (prop === "fork" && importsFork) return true; + return options.globalSymbols.has(prop) || selfFunctionImports.has(prop); + }, + }), + "GOT.mem": new Proxy({} as Record, { + get(_target, prop: string) { + return getOrCreateGOTEntry(prop, "mem"); + }, + }), + "GOT.func": new Proxy({} as Record, { + get(_target, prop: string) { + return getOrCreateGOTEntry(prop, "func"); + }, + }), + }; - // Relocate exports: data address globals need memoryBase added - const relocatedExports: Record = {}; - for (const [exportName, exportValue] of Object.entries(instance.exports)) { - if (exportValue instanceof WebAssembly.Global) { + // Instantiate synchronously after validating the side-module fork contract. + instance = new WebAssembly.Instance(module, imports); + + // A threaded wasm-ld side module initializes its mutable __tls_base from + // __memory_base in the start function. Fork-child memory already carries + // the parent's `__wasm_init_memory_flag == 2`, so the fresh child instance + // skips that initialization and otherwise leaves __tls_base at zero. + // Capture the live parent value and restore it during replay without + // calling __wasm_init_tls: the latter would overwrite copied, live TLS + // state (including the C++ unwinder's landing-pad context) with .tdata. + const tlsSizeExport = instance.exports.__tls_size; + const tlsSize = tlsSizeExport instanceof WebAssembly.Global + ? Number(tlsSizeExport.value) + : 0; + let tlsBase: number | undefined; + if (metadata.tlsExports.size > 0 && !(tlsSizeExport instanceof WebAssembly.Global)) { + throw new Error(`${name}: TLS exports require an exported __tls_size global`); + } + if (!Number.isSafeInteger(tlsSize) || tlsSize < 0) { + throw new Error(`${name}: invalid side-module TLS size ${String(tlsSize)}`); + } + if (tlsSize > 0) { + const tlsBaseExport = instance.exports.__tls_base; + const tlsAlignExport = instance.exports.__tls_align; + if (!(tlsBaseExport instanceof WebAssembly.Global)) { + throw new Error( + `${name}: TLS-bearing side modules must export mutable __tls_base for fork replay`, + ); + } + if (!(tlsAlignExport instanceof WebAssembly.Global)) { + throw new Error(`${name}: TLS-bearing side modules must export __tls_align`); + } + const tlsAlign = Number(tlsAlignExport.value); + if ( + !Number.isSafeInteger(tlsAlign) + || tlsAlign <= 0 + || (tlsAlign & (tlsAlign - 1)) !== 0 + ) { + throw new Error(`${name}: invalid side-module TLS alignment ${String(tlsAlign)}`); + } + + const initialRawTlsBase = tlsBaseExport.value; + const expectedTlsBaseType = (options.ptrWidth ?? 4) === 8 ? "bigint" : "number"; + if (typeof initialRawTlsBase !== expectedTlsBaseType) { + throw new Error( + `${name}: __tls_base type does not match the ${(options.ptrWidth ?? 4) * 8}-bit process pointer width`, + ); + } try { - (exportValue as any).value = (exportValue as any).value; - relocatedExports[exportName] = exportValue; + // A self-assignment is the only portable reflection available for + // distinguishing a mutable WebAssembly.Global from an immutable one. + tlsBaseExport.value = initialRawTlsBase; } catch { - relocatedExports[exportName] = new WebAssembly.Global( - { value: "i32", mutable: false }, - (exportValue as WebAssembly.Global).value + memoryBase, + throw new Error(`${name}: exported __tls_base must be mutable for fork replay`); + } + if (replay) { + if (!Number.isSafeInteger(replay.tlsBase) || replay.tlsBase! <= 0) { + throw new Error(`${name}: fork replay is missing a valid side-module TLS base`); + } + try { + tlsBaseExport.value = typeof initialRawTlsBase === "bigint" + ? BigInt(replay.tlsBase!) + : replay.tlsBase!; + } catch { + throw new Error(`${name}: exported __tls_base must be mutable for fork replay`); + } + } + tlsBase = Number(tlsBaseExport.value); + // Address zero is reserved as the archive's explicit "no TLS" sentinel. + // A real TLS allocation cannot live there: the process memory allocator + // always returns a positive address and the null page must stay invalid. + if (!Number.isSafeInteger(tlsBase) || tlsBase <= 0) { + throw new Error(`${name}: invalid side-module TLS base ${String(tlsBase)}`); + } + if (tlsBase % tlsAlign !== 0) { + throw new Error( + `${name}: side-module TLS base 0x${tlsBase.toString(16)} is not aligned to ${tlsAlign}`, ); } - } else { - relocatedExports[exportName] = exportValue; + const tlsEnd = tlsBase + tlsSize; + const moduleMemoryEnd = memoryBase + metadata.memorySize; + if ( + !Number.isSafeInteger(tlsEnd) + || tlsBase < memoryBase + || tlsEnd > moduleMemoryEnd + ) { + throw new Error( + `${name}: TLS range 0x${tlsBase.toString(16)}..0x${tlsEnd.toString(16)} ` + + `escapes module reservation 0x${memoryBase.toString(16)}..0x${moduleMemoryEnd.toString(16)}`, + ); + } + if (tlsEnd > options.memory.buffer.byteLength) { + throw new Error( + `${name}: TLS range 0x${tlsBase.toString(16)}..0x${tlsEnd.toString(16)} exceeds memory`, + ); + } + } else if (replay?.tlsBase !== undefined) { + throw new Error(`${name}: fork replay supplied TLS state for a module without TLS`); } - } - // Update GOT with this library's exports - for (const [exportName, exportValue] of Object.entries(relocatedExports)) { - if (exportName.startsWith("__")) continue; + // Relocate exports: data address globals need memoryBase added + const relocatedExports: Record = {}; + for (const [exportName, exportValue] of Object.entries(instance.exports)) { + if (exportValue instanceof WebAssembly.Global) { + try { + (exportValue as any).value = (exportValue as any).value; + relocatedExports[exportName] = exportValue; + } catch { + // These are scalar ABI facts, not data addresses. + if (exportName === "__tls_size" || exportName === "__tls_align") { + relocatedExports[exportName] = exportValue; + continue; + } + const rawValue = exportValue.value; + const relocationBase = metadata.tlsExports.has(exportName) + ? tlsBase + : memoryBase; + if (relocationBase === undefined) { + throw new Error(`${name}: TLS export ${exportName} has no live TLS base`); + } + relocatedExports[exportName] = new WebAssembly.Global( + { value: typeof rawValue === "bigint" ? "i64" : "i32", mutable: false }, + typeof rawValue === "bigint" + ? rawValue + BigInt(relocationBase) + : rawValue + relocationBase, + ); + } + } else { + relocatedExports[exportName] = exportValue; + } + } - if (typeof exportValue === "function") { - const tableIdx = options.table.length; - options.table.grow(1); - options.table.set(tableIdx, exportValue as unknown as Function); + // Update GOT with this library's exports + for (const [exportName, exportValue] of Object.entries(relocatedExports)) { + if (exportName.startsWith("__")) continue; + const alreadyDefined = options.globalSymbols.has(exportName); - const gotEntry = options.got.get(exportName); - if (gotEntry) { - gotEntry.value = tableIdx; - } - options.globalSymbols.set(exportName, exportValue as Function); - } else if (exportValue instanceof WebAssembly.Global) { - const addr = (exportValue as WebAssembly.Global).value; - const gotEntry = options.got.get(exportName); - if (gotEntry) { - gotEntry.value = addr as number; + if (typeof exportValue === "function") { + const tableIdx = options.table.length; + options.table.grow(1); + options.table.set(tableIdx, exportValue as unknown as Function); + + const gotEntry = options.got.get(exportName); + if (gotEntry && !alreadyDefined) { + gotEntry.value = tableIdx; + } + if (!alreadyDefined) { + options.globalSymbols.set(exportName, exportValue as Function); + } + } else if (exportValue instanceof WebAssembly.Global) { + const addr = (exportValue as WebAssembly.Global).value; + const gotEntry = options.got.get(exportName); + if (gotEntry && !alreadyDefined) { + gotEntry.value = addr as number; + } + if (!alreadyDefined) { + options.globalSymbols.set(exportName, exportValue); + } } - options.globalSymbols.set(exportName, exportValue); } - } - // Run data relocations - const applyRelocs = instance.exports.__wasm_apply_data_relocs as Function | undefined; - if (applyRelocs) { - applyRelocs(); - } + // Run data relocations + const applyRelocs = instance.exports.__wasm_apply_data_relocs as Function | undefined; + if (applyRelocs) { + applyRelocs(); + } - if (!replay) { - // Skip ctors in replay: parent already ran them and post-startup state - // (e.g. opcache accel_globals, registered INI entries) is in the - // memcpy'd data; re-running would clobber it. - const ctors = instance.exports.__wasm_call_ctors as Function | undefined; - if (ctors) { - ctors(); + if (!replay) { + // Skip ctors in replay: parent already ran them and post-startup state + // (e.g. opcache accel_globals, registered INI entries) is in the + // memcpy'd data; re-running would clobber it. + const ctors = instance.exports.__wasm_call_ctors as Function | undefined; + if (ctors) { + ctors(); + } } - } - const loaded: LoadedSharedLibrary = { - instance, - memoryBase, - tableBase, - exports: relocatedExports, - metadata, - name, - }; + const loaded: LoadedSharedLibrary = { + instance, + memoryBase, + tableBase, + exports: relocatedExports, + metadata, + name, + forkBufAddr: sideForkBufAddr || undefined, + tlsBase, + forkCapable: importsFork, + functionImports, + functionExports, + importsDynamicLookup, + }; - options.loadedLibraries.set(name, loaded); - return loaded; + options.loadedLibraries.set(name, loaded); + return loaded; + } catch (error) { + // Restore every mutable host-side linker structure we can. Table length and + // Wasm memory cannot shrink, so clear newly-addressable table slots and let + // the next successful archive entry record the resulting exact table base. + for (let i = tableRollbackBase; i < options.table.length; i++) { + try { options.table.set(i, null); } catch { /* best-effort for nullable funcref */ } + } + options.globalSymbols.clear(); + for (const [symbol, value] of symbolRollback) options.globalSymbols.set(symbol, value); + options.got.clear(); + for (const [symbol, snapshot] of gotRollback) { + try { snapshot.global.value = snapshot.value; } catch { /* immutable should not occur */ } + options.got.set(symbol, snapshot.global); + } + if (options.heapPointer && heapRollbackValue !== undefined) { + options.heapPointer.value = heapRollbackValue; + } + if (options.deallocateMemory) { + for (const allocation of allocations.reverse()) { + try { options.deallocateMemory(allocation.addr, allocation.size); } catch { /* preserve cause */ } + } + } + throw error; + } } /** @@ -467,6 +1155,7 @@ export async function loadSharedLibrary( wasmBytes: Uint8Array, options: LoadSharedLibraryOptions, ): Promise { + validateLongjmpConfiguration(options); const existing = options.loadedLibraries.get(name); if (existing) return existing; @@ -501,6 +1190,7 @@ export function loadSharedLibrarySync( options: LoadSharedLibraryOptions, replay?: DylinkReplayOptions, ): LoadedSharedLibrary { + validateLongjmpConfiguration(options); const existing = options.loadedLibraries.get(name); if (existing) return existing; @@ -548,6 +1238,7 @@ export class DynamicLinker { private lastError: string | null = null; constructor(options: LoadSharedLibraryOptions) { + validateLongjmpConfiguration(options); this.options = options; } diff --git a/host/src/generated/abi.ts b/host/src/generated/abi.ts index d92be819c7..4e15853748 100644 --- a/host/src/generated/abi.ts +++ b/host/src/generated/abi.ts @@ -1,10 +1,12 @@ /* GENERATED by `cargo xtask dump-abi`. Do not edit by hand. */ /* Regenerated by scripts/check-abi-version.sh; drift is a CI failure. */ -export const ABI_VERSION = 18 as const; +export const ABI_VERSION = 39 as const; export const ABI_CUSTOM_SECTION = "wasm-posix-abi" as const; export const ABI_KERNEL_EXPORT = "__abi_version" as const; +export const SCHED_AFFINITY_MASK_SIZE = 4 as const; + export const HOST_ADAPTER_VERSION = 1 as const; export const HOST_ADAPTER_MANIFEST_MAGIC = 1296781399 as const; export const HOST_ADAPTER_MANIFEST_VERSION = 1 as const; @@ -24,13 +26,19 @@ export const HOST_ADAPTER_REQUIRED_KERNEL_EXPORTS = [ "kernel_create_process", "kernel_create_process_with_stdio", "kernel_get_parent_pid", + "kernel_get_process_exit_signal", + "kernel_get_process_state", "kernel_handle_channel", + "kernel_has_sa_nocldstop", "kernel_host_adapter_manifest_len", "kernel_host_adapter_manifest_ptr", "kernel_mark_process_signaled", + "kernel_pipe_has_readers", + "kernel_posix_timer_fire", + "kernel_prepare_write_operation", "kernel_reap_exited_child", "kernel_remove_process", - "kernel_wait4_poll", + "kernel_wait_child_poll", ] as const; export const HOST_ADAPTER_OPTIONAL_KERNEL_EXPORTS = [ @@ -105,11 +113,37 @@ export const CH_SIG_HANDLER = 65564 as const; export const CH_SIG_FLAGS = 65568 as const; export const CH_SIG_OLD_MASK = 65576 as const; +export const WAIT_EVENT_EXITED = 1 as const; +export const WAIT_EVENT_STOPPED = 2 as const; +export const WAIT_EVENT_CONTINUED = 4 as const; +export const WAIT_WNOHANG = 1 as const; +export const WAIT_WUNTRACED = 2 as const; +export const WAIT_WSTOPPED = 2 as const; +export const WAIT_WEXITED = 4 as const; +export const WAIT_WCONTINUED = 8 as const; +export const WAIT_WNOWAIT = 16777216 as const; +export const WAIT_CLD_EXITED = 1 as const; +export const WAIT_CLD_KILLED = 2 as const; +export const WAIT_CLD_STOPPED = 5 as const; +export const WAIT_CLD_CONTINUED = 6 as const; +export const PROCESS_STATE_RUNNING = 0 as const; +export const PROCESS_STATE_STOPPED = 1 as const; +export const PROCESS_STATE_EXITED = 2 as const; +export const WAKE_PROCESS_STOPPED = 16 as const; +export const WAKE_PROCESS_CONTINUED = 32 as const; + export const STRUCT_SIZE_WASM_STAT = 88 as const; export const STRUCT_SIZE_WASM_DIRENT = 16 as const; export const STRUCT_SIZE_WASM_TIMESPEC = 16 as const; export const STRUCT_SIZE_WASM_POLL_FD = 8 as const; export const STRUCT_SIZE_WASM_STATFS = 72 as const; +export const STRUCT_SIZE_WASM_RUSAGE_WIRE = 144 as const; +export const STRUCT_SIZE_KERNEL_WAIT_RESULT = 160 as const; +export const KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET = 0 as const; +export const KERNEL_WAIT_RESULT_SI_CODE_OFFSET = 4 as const; +export const KERNEL_WAIT_RESULT_SI_STATUS_OFFSET = 8 as const; +export const KERNEL_WAIT_RESULT_CHILD_UID_OFFSET = 12 as const; +export const KERNEL_WAIT_RESULT_RUSAGE_OFFSET = 16 as const; export const HOST_INTERCEPTED_SYSCALLS = { SYS_EXECVE: 211, @@ -279,6 +313,7 @@ export const ABI_SYSCALLS = { SchedYield: 229, SchedGetparam: 230, SchedRrGetInterval: 236, + SchedGetaffinity: 238, EpollCreate1: 239, EpollCtl: 240, EpollPwait: 241, @@ -295,6 +330,7 @@ export const ABI_SYSCALLS = { Sendfile: 294, Preadv: 295, Pwritev: 296, + Lchown: 299, Fallocate: 308, TimerCreate: 326, TimerSettime: 327, @@ -327,6 +363,33 @@ export const ABI_SYSCALLS = { ThreadCancel: 415, } as const; +export const PATHCONF_NAMES = { + LINK_MAX: 0, + MAX_CANON: 1, + MAX_INPUT: 2, + NAME_MAX: 3, + PATH_MAX: 4, + PIPE_BUF: 5, + CHOWN_RESTRICTED: 6, + NO_TRUNC: 7, + VDISABLE: 8, + SYNC_IO: 9, + ASYNC_IO: 10, + PRIO_IO: 11, + SOCK_MAXBUF: 12, + FILESIZEBITS: 13, + REC_INCR_XFER_SIZE: 14, + REC_MAX_XFER_SIZE: 15, + REC_MIN_XFER_SIZE: 16, + REC_XFER_ALIGN: 17, + ALLOC_SIZE_MIN: 18, + SYMLINK_MAX: 19, + POSIX2_SYMLINKS: 20, + FALLOC: 21, + TEXTDOMAIN_MAX: 22, + TIMESTAMP_RESOLUTION: 23, +} as const; + export const ABI_SYSCALL_NAMES: Record = { 1: "open", 2: "close", @@ -490,6 +553,7 @@ export const ABI_SYSCALL_NAMES: Record = { 229: "sched_yield", 230: "sched_getparam", 236: "sched_rr_get_interval", + 238: "sched_getaffinity", 239: "epoll_create1", 240: "epoll_ctl", 241: "epoll_pwait", @@ -506,6 +570,7 @@ export const ABI_SYSCALL_NAMES: Record = { 294: "sendfile", 295: "preadv", 296: "pwritev", + 299: "lchown", 308: "fallocate", 326: "timer_create", 327: "timer_settime", @@ -552,6 +617,8 @@ export interface SyscallArgDesc { argIndex: number; direction: SyscallArgDirection; size: SyscallArgSizeSpec; + nullable?: boolean; + required?: boolean; copyRetvalAdd?: number; } @@ -758,7 +825,7 @@ export const SYSCALL_ARGS: Record = { { argIndex: 2, direction: "out", size: { type: "arg", argIndex: 3 } }, ], 108: [ - { argIndex: 1, direction: "out", size: { type: "fixed", size: 144 } }, + { argIndex: 1, direction: "out", size: { type: "fixed", size: 144 }, required: true }, ], 109: [ { argIndex: 0, direction: "in", size: { type: "cstring" } }, @@ -767,6 +834,13 @@ export const SYSCALL_ARGS: Record = { 110: [ { argIndex: 0, direction: "in", size: { type: "fixed", size: 8 } }, ], + 112: [ + { argIndex: 0, direction: "in", size: { type: "cstring" } }, + { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, + ], + 113: [ + { argIndex: 2, direction: "out", size: { type: "fixed", size: 8 }, required: true }, + ], 114: [ { argIndex: 1, direction: "out", size: { type: "deref", argIndex: 2 } }, { argIndex: 2, direction: "inout", size: { type: "fixed", size: 4 } }, @@ -791,7 +865,7 @@ export const SYSCALL_ARGS: Record = { { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 } }, ], 125: [ - { argIndex: 1, direction: "in", size: { type: "cstring" } }, + { argIndex: 1, direction: "in", size: { type: "cstring" }, nullable: true }, { argIndex: 2, direction: "in", size: { type: "fixed", size: 32 } }, ], 129: [ @@ -819,7 +893,7 @@ export const SYSCALL_ARGS: Record = { ], 139: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 4 } }, - { argIndex: 3, direction: "out", size: { type: "fixed", size: 32 } }, + { argIndex: 3, direction: "out", size: { type: "fixed", size: 144 } }, ], 140: [ { argIndex: 0, direction: "in", size: { type: "cstring" } }, @@ -859,6 +933,9 @@ export const SYSCALL_ARGS: Record = { 236: [ { argIndex: 1, direction: "out", size: { type: "fixed", size: 16 } }, ], + 238: [ + { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 }, required: true }, + ], 250: [ { argIndex: 2, direction: "in", size: { type: "fixed", size: 16 } }, { argIndex: 3, direction: "out", size: { type: "fixed", size: 16 } }, @@ -876,6 +953,13 @@ export const SYSCALL_ARGS: Record = { 272: [ { argIndex: 1, direction: "in", size: { type: "cstring" } }, ], + 288: [ + { argIndex: 2, direction: "out", size: { type: "fixed", size: 128 }, required: true }, + { argIndex: 4, direction: "out", size: { type: "fixed", size: 144 }, nullable: true }, + ], + 299: [ + { argIndex: 0, direction: "in", size: { type: "cstring" } }, + ], 326: [ { argIndex: 1, direction: "in", size: { type: "fixed", size: 16 } }, { argIndex: 2, direction: "out", size: { type: "fixed", size: 4 } }, diff --git a/host/src/host-diagnostic.ts b/host/src/host-diagnostic.ts new file mode 100644 index 0000000000..1b267b39b8 --- /dev/null +++ b/host/src/host-diagnostic.ts @@ -0,0 +1,16 @@ +/** + * Host-runtime diagnostic delivered outside a guest process's fd 2 stream. + * + * `status` is present when the diagnostic describes a process disposition. + * Protocol/setup failures that are not tied to an exit status leave it absent. + */ +export interface HostDiagnostic { + pid: number; + status?: number; + source: string; + message: string; +} + +export interface HostDiagnosticMessage extends HostDiagnostic { + type: "host_diagnostic"; +} diff --git a/host/src/index.ts b/host/src/index.ts index a65bc990b3..ae8e16de69 100644 --- a/host/src/index.ts +++ b/host/src/index.ts @@ -13,7 +13,17 @@ export type { LockInfo } from "./shared-lock-table"; export { NodeWorkerAdapter, MockWorkerAdapter, MockWorkerHandle } from "./worker-adapter"; export { centralizedWorkerMain, centralizedThreadWorkerMain } from "./worker-main"; export type { MessagePort as WorkerMessagePort } from "./worker-main"; -export type { KernelConfig, PlatformIO, StatResult, StatfsResult, NetworkIO } from "./types"; +export type { + KernelConfig, + NetworkIO, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "./types"; +export { PATHCONF_NAMES } from "./generated/abi"; +export { filesystemPathconf } from "./pathconf"; +export type { PathconfProfile } from "./pathconf"; export { TcpNetworkBackend, FetchNetworkBackend } from "./networking"; export type { FetchBackendOptions, HttpRequest, HttpResponse } from "./networking"; export type { WorkerAdapter, WorkerHandle } from "./worker-adapter"; @@ -91,6 +101,7 @@ export { WasiShim, WasiExit } from "./wasi-shim"; export { isWasiModule, wasiModuleImportsMemory, wasiModuleDefinesMemory } from "./wasi-detect"; export { NodeKernelHost } from "./node-kernel-host"; export type { NodeKernelHostOptions, SpawnOptions } from "./node-kernel-host"; +export type { HostDiagnostic } from "./host-diagnostic"; export type { MainToKernelMessage, KernelToMainMessage, diff --git a/host/src/kernel-worker.ts b/host/src/kernel-worker.ts index 9a182a4971..31709f3363 100644 --- a/host/src/kernel-worker.ts +++ b/host/src/kernel-worker.ts @@ -21,7 +21,7 @@ * 72 64KB data transfer buffer */ -import { WasmPosixKernel, type KernelPointer } from "./kernel"; +import { negErrno, WasmPosixKernel, type KernelPointer } from "./kernel"; import { SharedLockTable } from "./shared-lock-table"; import { buildRawHttpRequest, @@ -55,7 +55,29 @@ import { HOST_INTERCEPTED_SYSCALLS, PROCESS_MEMORY_PAGES_PER_THREAD_SLOT, PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE, + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + PROCESS_STATE_EXITED, + PROCESS_STATE_RUNNING, + PROCESS_STATE_STOPPED, + SCHED_AFFINITY_MASK_SIZE, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + STRUCT_SIZE_WASM_RUSAGE_WIRE, SYSCALL_ARGS, + WAIT_EVENT_CONTINUED, + WAIT_EVENT_EXITED, + WAIT_EVENT_STOPPED, + WAIT_WCONTINUED, + WAIT_WEXITED, + WAIT_WNOHANG, + WAIT_WNOWAIT, + WAIT_WSTOPPED, + WAIT_WUNTRACED, + WAKE_PROCESS_CONTINUED, + WAKE_PROCESS_STOPPED, type SyscallArgDesc, } from "./generated/abi"; import { validateKernelHostAdapterManifest } from "./host-adapter-manifest"; @@ -81,11 +103,29 @@ function concatChunksLocal(chunks: Uint8Array[]): Uint8Array { return out; } +/** @internal Exact process-generation guard for async worker-entry continuations. */ +export function isCurrentProcessGeneration( + registry: ReadonlyMap, + pid: number, + expected: T, + memory: WebAssembly.Memory, + execHandoffActive: boolean = false, +): boolean { + return !execHandoffActive + && registry.get(pid) === expected + && expected.memory === memory; +} + /** Channel status values */ const CH_IDLE = CHANNEL_STATUS_IDLE; const CH_PENDING = CHANNEL_STATUS_PENDING; const CH_COMPLETE = CHANNEL_STATUS_COMPLETE; +/** SIGEV_NONE crosses the host timer boundary as signal number zero. */ +export function shouldDeliverPosixTimerSignal(signo: number): boolean { + return Number.isInteger(signo) && signo > 0 && signo <= 64; +} + /** * Size of the wpk_fork save buffer. Each channel reserves * `[channelOffset - FORK_BUF_SIZE, channelOffset)` for the unwind frames and @@ -96,10 +136,70 @@ const CH_COMPLETE = CHANNEL_STATUS_COMPLETE; const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; /** Errno values */ +const E2BIG = 7; const EAGAIN = 11; +const EACCES = 13; +const EBADF = 9; +const EADDRNOTAVAIL = 99; +const EEXIST = 17; +const EFAULT = 14; +const EIO = 5; +const EINVAL = 22; +const ENODEV = 19; +const ENOMEM = 12; +const ENAMETOOLONG = 36; +const ENOENT = 2; +const ENOSYS = 38; +const ENOTSUP = 95; const ETIMEDOUT = 110; +const EALREADY = 114; +const EINPROGRESS = 115; const EINTR_ERRNO = 4; +function cstringCopySize( + memory: Uint8Array, + ptr: number, + capacity: number, +): { size: number } | { errno: number } { + if (!Number.isSafeInteger(ptr) || ptr <= 0 || ptr >= memory.length) { + return { errno: EFAULT }; + } + if (capacity <= 0) return { errno: ENAMETOOLONG }; + + const memoryAvailable = memory.length - ptr; + const scanLength = Math.min(memoryAvailable, capacity); + const nul = memory.subarray(ptr, ptr + scanLength).indexOf(0); + if (nul >= 0) return { size: nul + 1 }; + + return { + errno: memoryAvailable < capacity ? EFAULT : ENAMETOOLONG, + }; +} + +function isValidMemoryRange( + memory: Uint8Array, + ptr: number, + size: number, +): boolean { + return Number.isSafeInteger(ptr) + && ptr > 0 + && Number.isSafeInteger(size) + && size >= 0 + && ptr <= memory.length - size; +} + +/** + * Maximum combined exec argv + environment representation: UTF-8 strings, + * their terminating NUL bytes, and one source-width pointer per entry plus + * each list's terminating null pointer. This matches the advertised 4 MiB + * _SC_ARG_MAX boundary without imposing a separate argument-count ceiling. + * Individual entries must also fit one bounded host scratch transfer. + */ +const EXEC_METADATA_MAX_BYTES = 4 * 1024 * 1024; +const EXEC_PATH_MAX_BYTES = 4096; +const PROCESS_METADATA_ARGV = 0; +const PROCESS_METADATA_ENVIRONMENT = 1; + /** Syscall numbers for sleep/delay */ const SYS_NANOSLEEP = ABI_SYSCALLS.Nanosleep; const SYS_USLEEP = ABI_SYSCALLS.Usleep; @@ -115,6 +215,7 @@ const SYS_EPOLL_CREATE = ABI_SYSCALLS.EpollCreate; const SYS_EPOLL_CTL = ABI_SYSCALLS.EpollCtl; const SYS_EPOLL_WAIT = ABI_SYSCALLS.EpollWait; const SYS_RT_SIGTIMEDWAIT = ABI_SYSCALLS.RtSigtimedwait; +const SYS_SCHED_GETAFFINITY = ABI_SYSCALLS.SchedGetaffinity; /** * Grace period for signal-mask-swapping ppoll/pselect wakeups after a pipe @@ -125,6 +226,10 @@ const SIGNAL_SAFE_POLL_WAKE_DELAY_MS = 50; /** Syscall numbers for signals */ const SYS_KILL = ABI_SYSCALLS.Kill; +// wasm32/wasm64 musl route pthread-directed signals through tkill(2). +// The kernel dispatcher and both target syscall headers assign it number 204. +const SYS_TKILL = 204; +const SYS_RT_SIGQUEUEINFO = ABI_SYSCALLS.RtSigqueueinfo; /** Syscall numbers for fork/exec/clone */ const SYS_EXECVE = HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE; @@ -143,20 +248,11 @@ const SYS_WAITID = ABI_SYSCALLS.Waitid; * See libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c for the design. */ const SYS_THREAD_CANCEL = ABI_SYSCALLS.ThreadCancel; -/** waitpid options */ -const WNOHANG = 1; -const WNOWAIT = 0x1000000; -const WEXITED = 4; - /** waitid idtype */ const P_ALL = 0; const P_PID = 1; const P_PGID = 2; -/** CLD_* codes for siginfo_t */ -const CLD_EXITED = 1; -const CLD_KILLED = 2; - /** SIGCHLD */ const SIGCHLD = 17; const SIGALRM = 14; @@ -168,9 +264,19 @@ const SIGALRM = 14; const SIGKILL = 9; /** Network ioctl request codes */ +const SIOCGIFNAME = 0x8910; const SIOCGIFCONF = 0x8912; const SIOCGIFHWADDR = 0x8927; const SIOCGIFADDR = 0x8915; +const SIOCGIFINDEX = 0x8933; +const AF_INET = 2; +const ARPHRD_ETHER = 1; +const ARPHRD_LOOPBACK = 772; +const IF_NAMESIZE = 16; +const VIRTUAL_INTERFACES = [ + { name: "lo", index: 1, loopback: true }, + { name: "eth0", index: 2, loopback: false }, +] as const; /** Ioctl syscall number */ const SYS_IOCTL = ABI_SYSCALLS.Ioctl; @@ -178,6 +284,7 @@ const SYS_IOCTL = ABI_SYSCALLS.Ioctl; /** Syscall numbers for memory management */ const SYS_MMAP = ABI_SYSCALLS.Mmap; const SYS_MUNMAP = ABI_SYSCALLS.Munmap; +const SYS_MPROTECT = ABI_SYSCALLS.Mprotect; const SYS_BRK = ABI_SYSCALLS.Brk; const SYS_MREMAP = ABI_SYSCALLS.Mremap; const SYS_MSYNC = ABI_SYSCALLS.Msync; @@ -185,6 +292,19 @@ const SYS_WRITE = ABI_SYSCALLS.Write; const SYS_READ = ABI_SYSCALLS.Read; const SYS_PREAD = ABI_SYSCALLS.Pread; const SYS_PWRITE = ABI_SYSCALLS.Pwrite; +const SYS_FSYNC = ABI_SYSCALLS.Fsync; +const SYS_FDATASYNC = ABI_SYSCALLS.Fdatasync; +const SYS_FTRUNCATE = ABI_SYSCALLS.Ftruncate; +const SYS_TRUNCATE = ABI_SYSCALLS.Truncate; +const SYS_FALLOCATE = ABI_SYSCALLS.Fallocate; +const SYS_SENDFILE = ABI_SYSCALLS.Sendfile; +// Implemented by the kernel dispatcher but not yet classified in generated +// host marshalling. NULL-offset calls still traverse the ordinary channel. +const SYS_COPY_FILE_RANGE = 290; +const SYS_SPLICE = 291; +const SYS_DUP = ABI_SYSCALLS.Dup; +const SYS_DUP2 = ABI_SYSCALLS.Dup2; +const SYS_DUP3 = ABI_SYSCALLS.Dup3; const SYS_SEND = ABI_SYSCALLS.Send; const SYS_RECV = ABI_SYSCALLS.Recv; const SYS_SENDTO = ABI_SYSCALLS.Sendto; @@ -199,7 +319,26 @@ const MSG_DONTWAIT = 0x0040; /** mmap flags */ const MAP_SHARED = 0x01; +const PROT_READ = 0x01; +const PROT_WRITE = 0x02; +const MAP_FIXED = 0x10; const MAP_ANONYMOUS = 0x20; +const O_RDONLY = 0; +const O_WRONLY = 1; +const O_RDWR = 2; +const O_ACCMODE = 3; +const O_TRUNC = 0o1000; +const FILE_PAGE_SIZE = 4096; +const AT_FDCWD = -100; + +const F_DUPFD = 0; +const F_GETFL = 3; +const F_DUPFD_CLOFORK = 1028; +const F_DUPFD_CLOEXEC = 1030; + +function alignWasmPageLength(len: number): number { + return Math.ceil(len / WASM_PAGE_SIZE) * WASM_PAGE_SIZE; +} /** Syscall numbers for scatter/gather I/O */ const SYS_WRITEV = ABI_SYSCALLS.Writev; @@ -219,10 +358,13 @@ const SYS_SHMDT = ABI_SYSCALLS.Shmdt; const SYS_MQ_TIMEDSEND = ABI_SYSCALLS.MqTimedsend; const SYS_MQ_TIMEDRECEIVE = ABI_SYSCALLS.MqTimedreceive; +const SYS_OPEN = ABI_SYSCALLS.Open; +const SYS_OPENAT = ABI_SYSCALLS.Openat; const SYS_CLOSE = ABI_SYSCALLS.Close; /** IPC constants (must match musl) */ const IPC_64 = 0x100; +const SHM_RDONLY = 0o10000; const F_GETLK = 5; const F_SETLK = 6; @@ -323,7 +465,7 @@ export interface ProcessSnapshot { vsizeBytes: number; /** Current WebAssembly.Memory buffer size for this process, in bytes. */ memoryBytes?: number; - /** 'R' (running) | 'Z' (zombie); future: 'S','D','T','I'. */ + /** Process state code exposed through procfs-style snapshots. */ state: "R" | "Z" | "S" | "D" | "T" | "I"; /** Basename of argv[0], or "[kernel]" for an empty argv. */ comm: string; @@ -431,7 +573,8 @@ const ERRNO_NAMES: Record = { 95: "EOPNOTSUPP", 97: "EAFNOSUPPORT", 98: "EADDRINUSE", 99: "EADDRNOTAVAIL", 100: "ENETDOWN", 103: "ECONNABORTED", 104: "ECONNRESET", 106: "EISCONN", 107: "ENOTCONN", - 110: "ETIMEDOUT", 111: "ECONNREFUSED", 115: "EINPROGRESS", + 110: "ETIMEDOUT", 111: "ECONNREFUSED", 114: "EALREADY", + 115: "EINPROGRESS", }; /** Info about a registered thread channel. */ @@ -447,6 +590,10 @@ interface ChannelInfo { * retry/sleep/fork/exec path. Prevents the poller from re-entering a * channel that is already in flight. Only used when usePolling=true. */ handling?: boolean; + /** Absolute deadline for the current finite poll/select/epoll wait. */ + readinessDeadline?: number; + /** Force the next readiness dispatch to perform a zero-time final check. */ + readinessFinalCheck?: boolean; } /** Info about a registered process. */ @@ -464,10 +611,90 @@ interface ProcessRegistration { explicitMaxAddr: boolean; } +/** + * Host metadata for a MAP_SHARED interval. File mappings retain the existing + * fd-backed writeback path. Anonymous mappings additionally point at a + * host-owned backing and keep a snapshot of the bytes this process last saw. + */ +interface SharedMmapMapping { + fd: number; + fileOffset: number; + len: number; + writable: boolean; + /** Whether the original fd permits a later PROT_WRITE upgrade. */ + writeAllowed?: boolean; + backingKind?: "anonymous" | "file"; + backingKey?: string; + snapshot?: Uint8Array; + seenVersion?: number; +} + +interface SharedMmapFdStat { + dev: bigint; + ino: bigint; + size: number; + mode: number; + /** Concrete host handle used by fstat, or null for kernel-owned files. */ + hostHandle: number | null; +} + +interface SharedMmapBacking { + key: string; + handle: number; + writable: boolean; + /** Authoritative size from the stable handle's fstat. */ + size: number; + sizeValid: boolean; + pages: Map; + dirtyPages: Set; + refCount: number; + version: number; +} + +type SharedMmapHostResult = + { kind: "ok"; value: T } | { kind: "error"; errno: number }; + +type FileSharedMmapResult = + | { kind: "mapped" } + | { kind: "unsupported" } + | { kind: "error"; errno: number }; + +interface PreparedFileSharedMmap { + fd: number; + fileOffset: number; + len: number; + writable: boolean; + writeAllowed: boolean; + backing: SharedMmapBacking; +} + +type FileSharedMmapPreparationResult = + | { kind: "prepared"; context: PreparedFileSharedMmap } + | { kind: "unsupported" } + | { kind: "error"; errno: number }; + +interface AnonymousSharedMmapBacking { + key: string; + bytes: Uint8Array; + refCount: number; + version: number; +} + +interface SysvShmMapping { + segId: number; + size: number; + readOnly: boolean; + snapshot: Uint8Array; + seenVersion: number; +} + interface RegisterProcessOptions { skipKernelCreate?: boolean; argv?: string[]; + env?: string[]; ptrWidth?: 4 | 8; + /** Width of the exec caller's argv/envp pointer arrays for ARG_MAX accounting. */ + metadataPtrWidth?: 4 | 8; /** Required for new kernel Process creation; ignored when skipKernelCreate is true. */ stdio?: RegisterProcessStdio; /** Initial program break after any host-owned low control pages. */ @@ -508,10 +735,60 @@ function encodeStdioKind(kind: RegisterProcessStdioKind): number { } type WaitPollResult = - | { kind: "exited"; childPid: number; waitStatus: number } + | { + kind: "event"; + childPid: number; + waitStatus: number; + siCode: number; + siStatus: number; + childUid: number; + rusage: Uint8Array; + } | { kind: "running" } | { kind: "error"; errno: number }; +interface WaitingForChild { + parentPid: number; + channel: ChannelInfo; + origArgs: number[]; + pid: number; + options: number; + syscallNr: number; +} + +interface PreparedChannelCompletion { + kind: "marshalled" | "raw"; + outputWrites: Array<{ ptr: number; bytes: Uint8Array }>; + retVal: number; + errVal: number; + /** Output bytes/shared backing have reached guest-visible memory. */ + materialized: boolean; + /** Normal completions relisten themselves; raw callers opt in explicitly. */ + relistenRequested: boolean; +} + +interface ParkedChannelCompletion { + prepared: PreparedChannelCompletion; + /** A raw-completion caller may request this after the completion was parked. */ + relistenRequested: boolean; +} + +interface OwnedKernelWakeEvent { + wakeIdx: number; + wakeType: number; +} + +interface DeferredProcessWorkerStart { + expectedMemory: WebAssembly.Memory; + start: () => void; + cancel: () => void; + /** Return true when operation-specific rollback fully handled the failure. */ + onStartError?: (error: unknown) => boolean; +} + +export type ProcessWorkerStartDisposition = + "started" | "deferred" | "dead" | "stale"; + /** * Context describing a fork() initiated from a non-main thread. Set on * `onFork`'s optional `threadFork` arg by `handleFork` when it detects @@ -539,6 +816,7 @@ export interface ForkFromThreadContext { export interface ResolvedSpawnProgram { programBytes: ArrayBuffer; + programModule: WebAssembly.Module; argv: string[]; } @@ -546,13 +824,12 @@ export interface SpawnResolveError { errno: number; } -export type SpawnProgramResolution = ArrayBuffer | ResolvedSpawnProgram | SpawnResolveError; +export type SpawnProgramResolution = ResolvedSpawnProgram | SpawnResolveError; function isSpawnResolveError( resolution: SpawnProgramResolution, ): resolution is SpawnResolveError { - return !(resolution instanceof ArrayBuffer) && - "errno" in resolution && + return "errno" in resolution && typeof resolution.errno === "number"; } @@ -592,13 +869,19 @@ export interface CentralizedKernelCallbacks { * new binary, and call registerProcess with skipKernelCreate. * Returns 0 on success, negative errno on error. */ - onExec?: (pid: number, path: string, argv: string[], envp: string[]) => Promise; + onExec?: ( + pid: number, + path: string, + argv: string[], + envp: string[], + callerTid: number, + ) => Promise; /** - * Pre-flight resolution step for SYS_SPAWN. Returns the program bytes - * for `path` (or `{ programBytes, argv }` when resolution rewrites argv, - * e.g. a shebang script), `{ errno }` for a located but unlaunchable - * program, or `null` for ENOENT. **Must NOT have side effects** — + * Pre-flight resolution step for SYS_SPAWN. Returns the validated program + * bytes, their compiled module, and launch argv for `path`, `{ errno }` for + * a located but unlaunchable program, or `null` for ENOENT. **Must NOT have + * side effects** — * `handleSpawn` calls this BEFORE `kernel_spawn_process` so that file * actions never run on a doomed PATH-iteration. POSIX requires * file_actions to run "exactly once," and `posix_spawnp`'s PATH-walk @@ -612,11 +895,13 @@ export interface CentralizedKernelCallbacks { onResolveSpawn?: (path: string, argv: string[]) => Promise; /** - * Launch a worker for the spawned child with already-resolved bytes - * and argv (from `onResolveSpawn`). The kernel has constructed the child Process - * descriptor under `childPid` and applied file actions + attrs by the - * time this is called. The callback instantiates a fresh Worker and - * registers it via `registerProcess({ skipKernelCreate: true })`. + * Launch a worker for the spawned child with the already-resolved bytes, + * compiled module, and argv from `onResolveSpawn`. The kernel has + * constructed the child Process descriptor under `childPid` with + * `parentPid` as its authoritative parent + * and applied file actions + attrs by the time this is called. The callback + * instantiates a fresh Worker and registers it via + * `registerProcess({ skipKernelCreate: true })`. * * Returns 0 on success, negative errno on failure. On non-zero return * the kernel descriptor is rolled back via `kernel_remove_process`. @@ -624,8 +909,13 @@ export interface CentralizedKernelCallbacks { * Distinct from `onExec` (which replaces the calling worker) and * `onFork` (which clones the parent's Memory): `onSpawn` always * creates a fresh Memory and runs the new program from `_start`. - */ - onSpawn?: (childPid: number, programBytes: ArrayBuffer, argv: string[], envp: string[]) => Promise; + */ + onSpawn?: ( + parentPid: number, + childPid: number, + program: ResolvedSpawnProgram, + envp: string[], + ) => Promise; /** * Called when a process calls clone (thread creation). The callback should @@ -635,9 +925,9 @@ export interface CentralizedKernelCallbacks { /** * Called after a pthread channel reaches SYS_EXIT and the kernel worker has - * performed the musl clear-TID wake used by pthread joiners. Return true when - * the host will terminate the backing Worker, so the syscall channel should - * not be completed back into guest code. + * performed the musl clear-TID wake and completed the exit channel. The host + * may now terminate the backing Worker without leaving its channel waiter + * attached to a slot that will later be reused. */ onThreadExit?: (pid: number, tid: number, channelOffset: number) => boolean; @@ -654,6 +944,20 @@ export interface CentralizedKernelCallbacks { onExitGroup?: (pid: number) => void; } +interface TcpListenerBridge { + server: import("net").Server; + pid: number; + port: number; + connections: Set; +} + +interface TcpListenerTarget { + pid: number; + fd: number; + /** Stable kernel accept-queue identity retained even if this fd is closed. */ + acceptWakeIdx?: number; +} + export class CentralizedKernelWorker { private kernel: WasmPosixKernel; private kernelInstance: WebAssembly.Instance | null = null; @@ -662,6 +966,8 @@ export class CentralizedKernelWorker { private kernelAbiVersion: number = 0; private processes = new Map(); private activeChannels: ChannelInfo[] = []; + /** Pids whose old image committed exec but whose replacement has no channel yet. */ + private execHandoffPids = new Set(); private scratchOffset = 0; private initialized = false; private nextChildPid = 100; @@ -717,38 +1023,67 @@ export class CentralizedKernelWorker { * tracked TID, such as the main process worker. */ private bindKernelTidForChannel(channel: ChannelInfo): void { - const tid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? 0; + const tid = + this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? 0; const setTid = this.kernelInstance?.exports.kernel_set_current_tid as ((tid: number) => void) | undefined; if (setTid) setTid(tid); } + + private guestTidForChannel(channel: ChannelInfo): number { + return ( + this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? + channel.pid + ); + } /** Alarm timers per process: pid → NodeJS.Timeout */ private alarmTimers = new Map>(); /** POSIX timers: "pid:timerId" → {timeout, interval?, signo} */ - private posixTimers = new Map; interval?: ReturnType; signo: number }>(); - /** Pending sleep timers per process: pid → {timer, channel, syscallNr, origArgs, retVal, errVal} */ - private pendingSleeps = new Map; - channel: ChannelInfo; - syscallNr: number; - origArgs: number[]; - retVal: number; - errVal: number; - }>(); + private posixTimers = new Map< + string, + { + timeout: ReturnType; + interval?: ReturnType; + signo: number; + } + >(); + /** Pending sleep timers keyed by exact process/thread channel generation. */ + private pendingSleeps = new Map< + ChannelInfo, + { + timer: ReturnType; + channel: ChannelInfo; + syscallNr: number; + origArgs: number[]; + retVal: number; + errVal: number; + } + >(); + /** Threads blocked in rt_sigtimedwait, keyed by `pid:channelOffset`. */ + private pendingSignalWaits = new Map< + string, + { + timer: ReturnType; + channel: ChannelInfo; + origArgs: number[]; + } + >(); + /** Finite rt_sigtimedwait deadlines retained across wake-driven retries. */ + private signalWaitDeadlines = new Map< + string, + { pid: number; deadline: number } + >(); /** Maps "pid:tid" to ctidPtr for CLONE_CHILD_CLEARTID on thread exit */ private threadCtidPtrs = new Map(); /** TCP listeners: "pid:fd" → { server, pid, port, connections } */ - private tcpListeners = new Map; - }>(); - /** TCP listener targets: port → list of {pid, fd} for round-robin dispatch. + private tcpListeners = new Map(); + /** TCP listener targets: port → listener aliases for round-robin dispatch. * When multiple processes share a listening socket (e.g., nginx master forks * workers), incoming connections are distributed among them. */ - private tcpListenerTargets = new Map>(); + private tcpListenerTargets = new Map(); private tcpListenerRRIndex = new Map(); + /** Virtual-network listener registration key for each shared TCP port. */ + private tcpVirtualListenerKeys = new Map(); /** UDP virtual-network endpoint bindings: "pid:sockIdx" */ private udpBindings = new Set(); /** Separate scratch buffer for TCP data pumping */ @@ -756,19 +1091,42 @@ export class CentralizedKernelWorker { /** Node.js net module (loaded dynamically for browser compatibility) */ private netModule: typeof import("net") | null = null; /** Deferred waitpid/waitid completions. Child matching/reap state is Rust-owned. */ - private waitingForChild: Array<{ - parentPid: number; - channel: ChannelInfo; - origArgs: number[]; - pid: number; - options: number; - syscallNr: number; - }> = []; + private waitingForChild: WaitingForChild[] = []; + /** Pids whose authoritative kernel state is Stopped. Updated only from the + * kernel wake-event stream, so ordinary syscall completion does not need an + * extra Wasm state query. */ + private stoppedPids = new Set(); + /** + * CONTINUED transitions observed while fork/spawn/exec has a live kernel + * Process but has not registered its replacement memory/channels yet. The + * first Worker launch must pass through resume preflight before executing. + */ + private pendingResumePids = new Set(); + /** Completed syscalls whose result publication is withheld until SIGCONT. */ + private parkedChannelCompletions = new Map< + ChannelInfo, + ParkedChannelCompletion + >(); + /** + * Caught signals dequeued during a stopped-process resume preflight. Keep + * their already-copied channel payload intact until that exact mailbox is + * actually published; retries before publication must not clear or replace + * the handler record. + */ + private resumePreparedSignals = new WeakSet(); + /** Pending/retry dispatches observed while their process is stopped. */ + private deferredStoppedChannels = new Map(); + /** Worker constructors held while the authoritative Process is stopped. + * Multiple entries are possible because pthread clones share one pid. */ + private deferredProcessWorkerStarts = new Map< + number, + Set + >(); /** Cached kernel memory typed array view (invalidated on memory.grow) */ private cachedKernelMem: Uint8Array | null = null; private cachedKernelBuffer: ArrayBuffer | null = null; - /** Pending poll/ppoll retries — keyed by channelOffset for per-thread tracking */ - private pendingPollRetries = new Map(); - /** Pending pselect6/select retries — keyed by channelOffset for per-thread tracking */ - private pendingSelectRetries = new Map>(); - /** Pending futex waits: channelOffset → { futexAddr, futexIndex }. + /** Pending futex waits keyed by exact channel generation. * Tracked so SYS_THREAD_CANCEL can force-wake a futex-blocked thread * by firing Atomics.notify on the address it is waiting on. The waitAsync * Promise in handleFutex then resolves and writes the channel result. */ - private pendingFutexWaits = new Map(); - /** Channel offsets with a cancellation request pending. Set by - * SYS_THREAD_CANCEL. Checked at the entry of every blocking syscall - * path (futex wait, pipe retry, poll retry) so a cancel that arrives - * before the target actually blocks still terminates the in-flight - * syscall instead of silently racing past it. Cleared when the cancel - * has been serviced (target's channel completed with -EINTR / woken). */ - private pendingCancels = new Set(); + private pendingFutexWaits = new Map< + ChannelInfo, + { + futexIndex: number; + /** Settle this exact wait once without racing its waitAsync callback. */ + interrupt?: (retVal: number, errVal: number) => void; + /** Retire a discarded channel without publishing a guest completion. */ + retire?: () => void; + } + >(); + /** Exact channel generations with a cancellation request pending. Set by + * SYS_THREAD_CANCEL as the pre-enqueue race guard for host-owned wait and + * futex entry. Already-tracked poll/select/pipe/wait/futex blockers are + * interrupted immediately; an otherwise untracked target relies on the + * authoritative guest pthread cancel flag at its next cancellation point. */ + private pendingCancels = new Set(); /** Profiling data: syscallNr → {count, totalTimeMs, retries} */ - private profileData: Map | null = - PROFILING ? new Map() : null; + private profileData: Map< + number, + { count: number; totalTimeMs: number; retries: number } + > | null = PROFILING ? new Map() : null; /** Per-process stdin buffers: pid → { data, offset } */ - private stdinBuffers = new Map(); + private stdinBuffers = new Map< + number, + { data: Uint8Array; offset: number } + >(); /** Processes with finite stdin (setStdinData). Reads return EOF when buffer exhausted. * Processes NOT in this set get EAGAIN (blocking) when no stdin data is available. */ private stdinFinite = new Set(); /** Active TCP connections per process for piggyback flushing */ - private tcpConnections = new Map void; - }>>(); - /** Per-process MAP_SHARED file-backed mappings: pid → Map */ - private sharedMappings = new Map>(); + private tcpConnections = new Map< + number, + Array<{ + sendPipeIdx: number; + scratchOffset: number; + clientSocket: import("net").Socket; + recvPipeIdx: number; + schedulePump: () => void; + }> + >(); + /** Per-process MAP_SHARED mappings: pid → Map. */ + private sharedMappings = new Map>(); + /** Host-owned byte stores for anonymous MAP_SHARED mappings. */ + private anonymousSharedBackings = new Map< + string, + AnonymousSharedMmapBacking + >(); + private nextAnonymousSharedBackingId = 1; + /** Stable host handles and page caches for file/POSIX MAP_SHARED objects. */ + private sharedMmapBackings = new Map(); + /** Prevent nested signal cleanup from releasing the same address space twice. */ + private sharedMemoryReleasePids = new Set(); + /** Process fd → resolved backing identity, including negative lookups. */ + private sharedMmapFdCache = new Map(); /** Host-side mirror of epoll interest lists: "pid:epfd" → interests. * Maintained by intercepting epoll_ctl results. Used by handleEpollPwait * to convert epoll_pwait to poll without calling kernel_handle_channel * (which crashes in Chrome for epoll_pwait due to a suspected V8 bug). */ private epollInterests = new Map>(); private lockTable: SharedLockTable | null = null; - /** Per-process shared memory mappings: pid → Map */ - private shmMappings = new Map>(); + /** Per-process SysV shared-memory attachments. */ + private shmMappings = new Map>(); + /** Authoritative segment version, incremented after each merged publication. */ + private shmSegmentVersions = new Map(); /** PTY index → pid mapping (for draining output after syscalls) */ private ptyIndexByPid = new Map(); @@ -942,9 +1328,7 @@ export class CentralizedKernelWorker { if (seconds > 0) { const timer = setTimeout(() => { this.alarmTimers.delete(pid); - if (this.processes.has(pid)) { - this.sendSignalToProcess(pid, SIGALRM); - } + this.sendSignalToProcess(pid, SIGALRM); }, seconds * 1000); this.alarmTimers.set(pid, timer); } @@ -998,31 +1382,35 @@ export class CentralizedKernelWorker { // valueMs > 0 means armed (0 = disarm, kernel ensures >= 1ms for armed timers) const delay = Math.max(0, valueMs); const timeout = setTimeout(() => { + const current = this.posixTimers.get(key); + if (!current || current.timeout !== timeout) return; if (!this.processes.has(pid)) { this.posixTimers.delete(key); return; } - this.sendSignalToProcess(pid, signo); + this.firePosixTimer(pid, timerId, signo); // Set up repeating interval if needed if (intervalMs > 0) { const iv = setInterval(() => { + const intervalEntry = this.posixTimers.get(key); + if (!intervalEntry || intervalEntry.interval !== iv) { + clearInterval(iv); + return; + } if (!this.processes.has(pid)) { - const entry = this.posixTimers.get(key); - if (entry?.interval) clearInterval(entry.interval); + clearInterval(iv); this.posixTimers.delete(key); return; } - // Check if signal is already pending (overrun) or new cycle - const intervalFire = this.kernelInstance!.exports - .kernel_posix_timer_interval_fire as ((pid: number, timerId: number) => number) | undefined; - const alreadyPending = intervalFire ? intervalFire(pid, timerId) : 0; - if (!alreadyPending) { - this.sendSignalToProcess(pid, signo); - } + this.firePosixTimer(pid, timerId, signo); }, intervalMs); const entry = this.posixTimers.get(key); - if (entry) entry.interval = iv; + if (entry?.timeout === timeout) { + entry.interval = iv; + } else { + clearInterval(iv); + } } else { this.posixTimers.delete(key); } @@ -1060,10 +1448,8 @@ export class CentralizedKernelWorker { // user program spawned against this kernel will have its own // `__abi_version` export compared against this value; mismatches // are refused before any syscall runs. - const abiVersionFn = - this.kernelInstance.exports[ABI_KERNEL_EXPORT] as - | (() => number) - | undefined; + const abiVersionFn = this.kernelInstance.exports[ABI_KERNEL_EXPORT] as + (() => number) | undefined; if (typeof abiVersionFn !== "function") { throw new Error( `kernel wasm is missing the ${ABI_KERNEL_EXPORT} export — refusing to run. ` + @@ -1122,6 +1508,21 @@ export class CentralizedKernelWorker { ): void { if (!this.initialized) throw new Error("Kernel not initialized"); + // Registration replaces every channel object for this pid. Exec keeps the + // authoritative stopped state; a genuinely fresh kernel Process does not. + this.discardStoppedChannelStateForProcess(pid, !options?.skipKernelCreate); + + if (options?.argv !== undefined || options?.env !== undefined) { + const metadataResult = this.validateExecMetadata( + options.argv ?? [], + options.env ?? [], + options.metadataPtrWidth ?? options.ptrWidth ?? 4, + ); + if (metadataResult < 0) { + throw new Error(`Process argv/environment exceeds exec metadata limits: errno ${-metadataResult}`); + } + } + // A fresh registration starts a new "generation" for this pid — even // if the same numeric pid was previously reaped (it can't be today // since nextChildPid is monotonic, but defensive), the new process @@ -1159,19 +1560,14 @@ export class CentralizedKernelWorker { } // Set process argv in kernel for /proc//cmdline - if (options?.argv && options.argv.length > 0) { - const setArgv = this.kernelInstance!.exports.kernel_set_process_argv as - ((pid: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; - if (setArgv) { - const encoder = new TextEncoder(); - const nullSep = options.argv.join("\0"); - const encoded = encoder.encode(nullSep); - // Write to kernel scratch area - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const scratchOffset = this.scratchOffset!; - kernelMem.set(encoded, scratchOffset); - setArgv(pid, this.toKernelPtr(scratchOffset), encoded.length); - } + if (options?.argv !== undefined) { + this.replaceProcessMetadata(pid, PROCESS_METADATA_ARGV, options.argv); + } + + // Keep kernel-owned environment state synchronized with the process + // worker. This matters for exec even when the replacement envp is empty. + if (options?.env !== undefined) { + this.replaceProcessMetadata(pid, PROCESS_METADATA_ENVIRONMENT, options.env); } // Cap mmap address space. New hosts pass the process memory maximum here @@ -1181,9 +1577,9 @@ export class CentralizedKernelWorker { const setMaxAddr = this.kernelInstance!.exports.kernel_set_max_addr as ((pid: number, maxAddr: KernelPointer) => number) | undefined; if (setMaxAddr) { - const maxAddr = options?.maxAddr ?? ( - channelOffsets.length > 0 ? Math.min(...channelOffsets) : undefined - ); + const maxAddr = + options?.maxAddr ?? + (channelOffsets.length > 0 ? Math.min(...channelOffsets) : undefined); if (maxAddr !== undefined) { setMaxAddr(pid, this.toKernelPtr(maxAddr)); } @@ -1234,6 +1630,123 @@ export class CentralizedKernelWorker { } } + /** + * Side-effect-free exec argv/environment validation. Call this before the + * irreversible exec commit so oversized metadata returns E2BIG to the old + * image instead of failing while the replacement worker is being installed. + */ + validateExecMetadata( + argv: readonly string[], + env: readonly string[], + ptrWidth: 4 | 8 = 4, + ): number { + const encoder = new TextEncoder(); + // Account for the null pointer terminating each vector even when it is + // explicitly empty. Pointer accounting both matches ARG_MAX semantics and + // bounds the number of zero-length entries without an arbitrary count cap. + let totalBytes = 2 * ptrWidth; + for (const value of [...argv, ...env]) { + const encodedLength = encoder.encode(value).byteLength; + if (encodedLength > CH_DATA_SIZE) return -E2BIG; + totalBytes += ptrWidth + encodedLength + 1; + if (!Number.isSafeInteger(totalBytes) || totalBytes > EXEC_METADATA_MAX_BYTES) { + return -E2BIG; + } + } + return 0; + } + + /** Whether this kernel supports lossless bounded argv+environment replacement. */ + supportsExecMetadataReplacement(): boolean { + const exports = this.kernelInstance?.exports; + return ( + typeof exports?.kernel_clear_process_metadata === "function" && + typeof exports?.kernel_push_process_metadata_entry === "function" + ); + } + + /** Replace argv or environ using bounded, entry-at-a-time scratch copies. */ + private replaceProcessMetadata( + pid: number, + kind: number, + values: readonly string[], + ): void { + const clear = this.kernelInstance!.exports.kernel_clear_process_metadata as + ((pid: number, kind: number) => number) | undefined; + const push = this.kernelInstance!.exports + .kernel_push_process_metadata_entry as + | (( + pid: number, + kind: number, + dataPtr: KernelPointer, + dataLen: number, + ) => number) + | undefined; + if (!clear || !push) { + // Additive ABI-16 compatibility for ordinary initial registrations: + // older kernels can still receive a small argv through their legacy + // aggregate setter. Exec preflight rejects before commit because that + // legacy surface cannot explicitly replace/clear the environment. + const setArgv = this.kernelInstance!.exports.kernel_set_process_argv as + | ((pid: number, dataPtr: KernelPointer, dataLen: number) => number) + | undefined; + if (kind !== PROCESS_METADATA_ARGV || !setArgv) { + throw new Error("Kernel missing bounded process metadata exports"); + } + const encoded = new TextEncoder().encode(values.join("\0")); + if (encoded.byteLength > CH_DATA_SIZE) { + throw new Error( + `Legacy process argv exceeds bounded scratch transport: errno ${E2BIG}`, + ); + } + new Uint8Array(this.kernelMemory!.buffer).set( + encoded, + this.scratchOffset, + ); + const result = setArgv( + pid, + this.toKernelPtr(this.scratchOffset), + encoded.byteLength, + ); + if (result < 0) { + throw new Error( + `Failed to replace process argv for pid ${pid}: errno ${-result}`, + ); + } + return; + } + + const clearResult = clear(pid, kind); + if (clearResult < 0) { + throw new Error( + `Failed to clear process metadata for pid ${pid}: errno ${-clearResult}`, + ); + } + + const encoder = new TextEncoder(); + for (const value of values) { + const encoded = encoder.encode(value); + if (encoded.byteLength > CH_DATA_SIZE) { + throw new Error( + `Process metadata entry exceeds bounded scratch transport: errno ${E2BIG}`, + ); + } + // A preceding Rust push can allocate and grow kernel Wasm memory, + // detaching the old ArrayBuffer view. Refresh it for every entry. + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + kernelMem.set(encoded, this.scratchOffset); + const pushResult = push( + pid, + kind, + this.toKernelPtr(this.scratchOffset), + encoded.byteLength, + ); + if (pushResult < 0) { + throw new Error(`Failed to append process metadata for pid ${pid}: errno ${-pushResult}`); + } + } + } + /** * Provide data that will be returned when the process reads from stdin (fd 0). * Data is returned in chunks until exhausted, then EOF is returned. @@ -1285,9 +1798,11 @@ export class CentralizedKernelWorker { setupPty(pid: number): number { const kernelPtyCreate = this.kernelInstance!.exports.kernel_pty_create as ((pid: number) => number) | undefined; - if (!kernelPtyCreate) throw new Error("Kernel missing kernel_pty_create export"); + if (!kernelPtyCreate) + throw new Error("Kernel missing kernel_pty_create export"); const ptyIdx = kernelPtyCreate(pid); - if (ptyIdx < 0) throw new Error(`kernel_pty_create failed: errno ${-ptyIdx}`); + if (ptyIdx < 0) + throw new Error(`kernel_pty_create failed: errno ${-ptyIdx}`); this.ptyIndexByPid.set(pid, ptyIdx); this.activePtyIndices.add(ptyIdx); return ptyIdx; @@ -1329,7 +1844,8 @@ export class CentralizedKernelWorker { * Resize a PTY and send SIGWINCH to the foreground process group. */ ptySetWinsize(ptyIdx: number, rows: number, cols: number): void { - const kernelPtySetWinsize = this.kernelInstance!.exports.kernel_pty_set_winsize as + const kernelPtySetWinsize = this.kernelInstance!.exports + .kernel_pty_set_winsize as ((ptyIdx: number, rows: number, cols: number) => number) | undefined; if (!kernelPtySetWinsize) return; kernelPtySetWinsize(ptyIdx, rows, cols); @@ -1341,13 +1857,14 @@ export class CentralizedKernelWorker { // pending we complete the sleep with EINTR so the glue can dispatch it. // Skipped pids (no signal queued) keep their original sleep deadline. const EINTR = 4; - for (const [pid, entry] of Array.from(this.pendingSleeps.entries())) { - if (!this.processes.has(pid)) continue; - this.dequeueSignalForDelivery(entry.channel); + for (const [sleepChannel, entry] of Array.from(this.pendingSleeps.entries())) { + if (!this.isRegisteredChannel(entry.channel)) continue; + this.dequeueSignalForDelivery(entry.channel, true); + if (this.finishSignalTermination(entry.channel)) continue; const view = new DataView(entry.channel.memory.buffer, entry.channel.channelOffset); if (view.getUint32(CH_SIG_SIGNUM, true) > 0) { clearTimeout(entry.timer); - this.pendingSleeps.delete(pid); + this.pendingSleeps.delete(sleepChannel); this.completeChannel( entry.channel, entry.syscallNr, entry.origArgs, SYSCALL_ARGS[entry.syscallNr], -1, EINTR, @@ -1401,7 +1918,14 @@ export class CentralizedKernelWorker { // Use the pre-allocated scratch area in kernel memory const buf = new Uint8Array(this.kernelMemory!.buffer); buf.set(encoded, this.scratchOffset); - kernelSetCwd(pid, this.toKernelPtr(this.scratchOffset), encoded.length); + const result = kernelSetCwd( + pid, + this.toKernelPtr(this.scratchOffset), + encoded.length, + ); + if (result < 0) { + throw new Error(`setCwd failed for pid ${pid}: errno ${-result}`); + } } /** @@ -1413,16 +1937,15 @@ export class CentralizedKernelWorker { if (ids.uid == null && ids.gid == null) return; const unchanged = 0xffffffff; - const direct = this.kernelInstance!.exports.kernel_set_process_credentials as + const direct = this.kernelInstance!.exports + .kernel_set_process_credentials as ((pid: number, uid: number, gid: number) => number) | undefined; if (direct) { - const result = direct( - pid, - ids.uid ?? unchanged, - ids.gid ?? unchanged, - ); + const result = direct(pid, ids.uid ?? unchanged, ids.gid ?? unchanged); if (result < 0) { - throw new Error(`setCredentials failed for pid ${pid}: errno ${-result}`); + throw new Error( + `setCredentials failed for pid ${pid}: errno ${-result}`, + ); } return; } @@ -1431,8 +1954,8 @@ export class CentralizedKernelWorker { // per-pid export existed: select the new process, then use the normal // syscall exports while it is still root. gid must be applied first, // because setting uid to a non-root value drops privilege. - const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as - ((pid: number) => void) | undefined; + const setCurrentPid = this.kernelInstance!.exports + .kernel_set_current_pid as ((pid: number) => void) | undefined; const setgid = this.kernelInstance!.exports.kernel_setgid as ((gid: number) => number) | undefined; const setuid = this.kernelInstance!.exports.kernel_setuid as @@ -1443,11 +1966,13 @@ export class CentralizedKernelWorker { setCurrentPid(pid); if (ids.gid != null) { const result = setgid(ids.gid); - if (result < 0) throw new Error(`setgid failed for pid ${pid}: errno ${-result}`); + if (result < 0) + throw new Error(`setgid failed for pid ${pid}: errno ${-result}`); } if (ids.uid != null) { const result = setuid(ids.uid); - if (result < 0) throw new Error(`setuid failed for pid ${pid}: errno ${-result}`); + if (result < 0) + throw new Error(`setuid failed for pid ${pid}: errno ${-result}`); } } finally { setCurrentPid(0); @@ -1503,7 +2028,11 @@ export class CentralizedKernelWorker { if (n <= 0) return []; // The kernel memory is a SharedArrayBuffer; TextDecoder refuses // shared views. Copy to a regular ArrayBuffer before parsing. - const shared = new Uint8Array(this.kernelMemory!.buffer, this.scratchOffset, n); + const shared = new Uint8Array( + this.kernelMemory!.buffer, + this.scratchOffset, + n, + ); const owned = new Uint8Array(n); owned.set(shared); const snapshots = parseProcSnapshots(owned); @@ -1527,11 +2056,15 @@ export class CentralizedKernelWorker { ((pid: number, ptr: KernelPointer, len: number) => number) | undefined; if (!readMaps) return null; const n = readMaps(pid, this.toKernelPtr(this.scratchOffset), SCRATCH_SIZE); - if (n < 0) return null; // -ESRCH or similar + if (n < 0) return null; // -ESRCH or similar if (n === 0) return ""; // SharedArrayBuffer view → TextDecoder doesn't accept shared views. // Copy out before decoding. - const shared = new Uint8Array(this.kernelMemory!.buffer, this.scratchOffset, n); + const shared = new Uint8Array( + this.kernelMemory!.buffer, + this.scratchOffset, + n, + ); const owned = new Uint8Array(n); owned.set(shared); return new TextDecoder("utf-8", { fatal: false }).decode(owned); @@ -1545,6 +2078,16 @@ export class CentralizedKernelWorker { const registration = this.processes.get(pid); if (!registration) return; + this.retireAsyncChannelsForProcess(pid); + this.discardStoppedChannelStateForProcess(pid); + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.parentPid !== pid && waiter.channel.pid !== pid, + ); + + // Shared backing publication and SysV detach require the process memory and + // kernel Process to remain available, so do this before either is removed. + this.releaseAllSharedMemoryForProcess(pid); + // Remove channels from active list this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); @@ -1556,9 +2099,11 @@ export class CentralizedKernelWorker { this.cleanupPendingPollRetries(pid); // Clean up pending select retries this.cleanupPendingSelectRetries(pid); + this.cleanupPendingSignalWaits(pid); // Clean up pending pipe readers/writers this.cleanupPendingPipeReaders(pid); this.cleanupPendingPipeWriters(pid); + this.cancelPendingSleepsForProcess(pid); // Clean up socket timeout timers for this process for (const [ch, timer] of this.socketTimeoutTimers) { if (ch.pid === pid) { @@ -1580,6 +2125,7 @@ export class CentralizedKernelWorker { this.removeFromKernelProcessTable(pid); this.processes.delete(pid); + this.execHandoffPids?.delete(pid); this.stdinFinite.delete(pid); this.stdinBuffers.delete(pid); @@ -1631,9 +2177,24 @@ export class CentralizedKernelWorker { this.lockTable.removeLocksByPid(pid); } + private cancelPendingSleepsForProcess(pid: number): void { + for (const [channel, sleep] of this.pendingSleeps) { + if (channel.pid !== pid) continue; + clearTimeout(sleep.timer); + this.pendingSleeps.delete(channel); + } + } + deactivateProcess(pid: number): void { + this.retireAsyncChannelsForProcess(pid); + this.discardStoppedChannelStateForProcess(pid); + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.parentPid !== pid && waiter.channel.pid !== pid, + ); + this.releaseAllSharedMemoryForProcess(pid); this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); this.processes.delete(pid); + this.execHandoffPids?.delete(pid); this.stdinFinite.delete(pid); this.stdinBuffers.delete(pid); this.releaseAdvisoryLocksForPid(pid); @@ -1651,16 +2212,13 @@ export class CentralizedKernelWorker { this.posixTimers.delete(key); } } - // Cancel any pending sleep timer for this process - const sleepTimer = this.pendingSleeps.get(pid); - if (sleepTimer) { - clearTimeout(sleepTimer.timer); - this.pendingSleeps.delete(pid); - } + // Cancel pending sleeps for every thread in this process. + this.cancelPendingSleepsForProcess(pid); // Clean up pending poll retries this.cleanupPendingPollRetries(pid); // Clean up pending select retries this.cleanupPendingSelectRetries(pid); + this.cleanupPendingSignalWaits(pid); // Clean up network listeners/endpoints for this process this.cleanupUdpBindings(pid); this.cleanupTcpListeners(pid); @@ -1670,5581 +2228,10201 @@ export class CentralizedKernelWorker { this.hostReaped.delete(pid); } + /** + * Validate the exec caller and apply deferred posix_spawn file actions. + * This is the fallible kernel preflight; no image-owned state is discarded. + */ + kernelExecPrepare(pid: number, callerTid: number = pid): number { + const prepare = this.kernelInstance!.exports.kernel_exec_prepare as + ((pid: number, callerTid: number) => number) | undefined; + if (!prepare) return 0; + + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + try { + return prepare(pid, callerTid); + } finally { + this.currentHandlePid = previousPid; + } + } + /** * Run kernel-side exec setup: close CLOEXEC fds, reset signal handlers. * Returns 0 on success, negative errno on failure. * Called by onExec callbacks after confirming the target program exists. */ - kernelExecSetup(pid: number): number { - const fn = this.kernelInstance!.exports.kernel_exec_setup as (pid: number) => number; - return fn(pid); + kernelExecSetup(pid: number, callerTid: number = pid): number { + const threadAware = this.kernelInstance!.exports + .kernel_exec_setup_for_thread as + ((pid: number, callerTid: number) => number) | undefined; + const legacy = this.kernelInstance!.exports.kernel_exec_setup as ( + pid: number, + ) => number; + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + try { + const listenerWakeSnapshot = this.snapshotExecTcpListenerWakeIds(pid); + const result = threadAware ? threadAware(pid, callerTid) : legacy(pid); + if (result === 0) { + // This is post-commit bookkeeping. Let failures propagate to the + // worker entry's fatal exec boundary; returning to the discarded + // caller or continuing with stale host mirrors would both be false. + this.pruneExecFdMirrors(pid, listenerWakeSnapshot); + } + return result; + } finally { + this.currentHandlePid = previousPid; + } } - /** - * Remove old channel/registration state for a process about to exec. - * Does NOT remove from kernel process table (exec keeps the same pid). - * Does NOT cancel timers (POSIX: timers are preserved across exec). - */ - prepareProcessForExec(pid: number): void { - // Remove channels from active list (stops listening on old memory) - this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); + /** Snapshot stable accept-queue identities before CLOEXEC closes aliases. */ + private snapshotExecTcpListenerWakeIds(pid: number): Map { + const getAcceptWake = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const snapshot = new Map(); + if (!getAcceptWake) return snapshot; + const remember = (port: number, fd: number, knownWakeIdx?: number) => { + // A target's stored queue token is its stable identity even after the + // numeric fd closes or is reused by a different listener. + const wakeIdx = knownWakeIdx ?? getAcceptWake(pid, fd); + if (wakeIdx >= 0) snapshot.set(`${port}:${fd}`, wakeIdx); + }; - // Clean up pending blocking retries (the old program's syscalls are dead) - this.cleanupPendingPollRetries(pid); - this.cleanupPendingSelectRetries(pid); - this.cleanupPendingPipeReaders(pid); - this.cleanupPendingPipeWriters(pid); - for (const [ch, timer] of this.socketTimeoutTimers) { - if (ch.pid === pid) { - clearTimeout(timer); - this.socketTimeoutTimers.delete(ch); + for (const [port, targets] of this.tcpListenerTargets) { + for (const target of targets) { + if (target.pid === pid) remember(port, target.fd, target.acceptWakeIdx); } } - - // Remove process registration (new one will be added by registerProcess) - this.processes.delete(pid); - } - - /** - * Remove a process from the kernel's PROCESS_TABLE. - * Called when a zombie is reaped by wait/waitpid. - */ - removeFromKernelProcessTable(pid: number): void { - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as (pid: number) => number; - removeProcess(pid); + const prefix = `${pid}:`; + for (const [key, listener] of this.tcpListeners) { + if (!key.startsWith(prefix)) continue; + const fd = Number(key.slice(prefix.length)); + const target = this.tcpListenerTargets.get(listener.port) + ?.find(entry => entry.pid === pid && entry.fd === fd); + remember(listener.port, fd, target?.acceptWakeIdx); + } + return snapshot; } - /** - * Add a new channel (e.g. for a thread) to an existing process registration. - * Uses the process's existing memory. If tid is provided, tracks the mapping - * so handleExit can identify thread exits. `threadFnPtr` / `threadArgPtr` - * are stored when the thread was created via clone() so `handleFork` can - * route a fork() from this thread back through its entry point. - */ - addChannel( + /** Resolve one listener identity in another process after fork/spawn actions. */ + private resolveInheritedListenerFd( pid: number, - channelOffset: number, - tid?: number, - threadFnPtr?: number, - threadArgPtr?: number, - ): void { - const registration = this.processes.get(pid); - if (!registration) throw new Error(`Process ${pid} not registered`); - - const channel: ChannelInfo = { - pid, - memory: registration.memory, - channelOffset, - i32View: new Int32Array(registration.memory.buffer, channelOffset), - consecutiveSyscalls: 0, - }; - - registration.channels.push(channel); - this.activeChannels.push(channel); + preferredFd: number, + wakeIdx?: number, + ): { fd: number; acceptWakeIdx?: number } | null { + const getAcceptWake = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getAcceptWake) { + return { + fd: preferredFd, + ...(wakeIdx !== undefined ? { acceptWakeIdx: wakeIdx } : {}), + }; + } - if (tid !== undefined) { - this.channelTids.set(`${pid}:${channelOffset}`, tid); + const liveWakeIdx = getAcceptWake(pid, preferredFd); + if (wakeIdx === undefined) { + return liveWakeIdx >= 0 + ? { fd: preferredFd, acceptWakeIdx: liveWakeIdx } + : null; } - if (threadFnPtr !== undefined && threadArgPtr !== undefined) { - this.threadForkContexts.set(`${pid}:${channelOffset}`, { - fnPtr: threadFnPtr, - argPtr: threadArgPtr, - }); + if (liveWakeIdx === wakeIdx) { + return { fd: preferredFd, acceptWakeIdx: wakeIdx }; } - // Lower the kernel's mmap ceiling only for legacy high-address thread - // control pages. Compact process memories reserve thread pages before the - // process's mmap base when the process is registered. - const setMaxAddr = this.kernelInstance!.exports.kernel_set_max_addr as - ((pid: number, maxAddr: KernelPointer) => number) | undefined; - if (setMaxAddr && !registration.explicitMaxAddr) { - const tlsPageAddr = channelOffset - 2 * WASM_PAGE_SIZE; - if (tlsPageAddr >= PROCESS_MMAP_BASE) { - setMaxAddr(pid, this.toKernelPtr(tlsPageAddr)); + const findListenerFd = this.kernelInstance!.exports + .kernel_find_listener_fd_by_accept_wake as + ((pid: number, wakeIdx: number) => number) | undefined; + let resolvedFd = findListenerFd?.(pid, wakeIdx) ?? -1; + if (!findListenerFd) { + // Compatibility with ABI 16 kernels predating the additive resolver. + for (let fd = 0; fd < 1024; fd++) { + if (getAcceptWake(pid, fd) === wakeIdx) { + resolvedFd = fd; + break; + } } } - - // In polling mode, the poller picks up new channels automatically. - if (!this.usePolling) { - this.listenOnChannel(channel); - } + return resolvedFd >= 0 + ? { fd: resolvedFd, acceptWakeIdx: wakeIdx } + : null; } /** - * Remove a channel from a process registration (e.g. when a thread exits). + * Install host-only descriptor mirrors for a kernel child that already + * exists. This runs synchronously before async Worker launch so parent exec + * cannot close the final listener backend in the handoff window. */ - removeChannel(pid: number, channelOffset: number): void { - const registration = this.processes.get(pid); - if (!registration) return; - - registration.channels = registration.channels.filter( - (ch) => ch.channelOffset !== channelOffset, - ); - this.activeChannels = this.activeChannels.filter( - (ch) => !(ch.pid === pid && ch.channelOffset === channelOffset), - ); - this.channelTids.delete(`${pid}:${channelOffset}`); - this.threadForkContexts.delete(`${pid}:${channelOffset}`); - } + private inheritHostFdMirrors( + parentPid: number, + childPid: number, + includeEpoll: boolean = true, + ): void { + const getAcceptWake = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + for (const [, targets] of this.tcpListenerTargets) { + for (const parentTarget of targets.filter( + (target) => target.pid === parentPid, + )) { + const parentWakeIdx = + parentTarget.acceptWakeIdx ?? + (() => { + const wakeIdx = getAcceptWake?.(parentPid, parentTarget.fd) ?? -1; + return wakeIdx >= 0 ? wakeIdx : undefined; + })(); + const childTarget = this.resolveInheritedListenerFd( + childPid, + parentTarget.fd, + parentWakeIdx, + ); + if (!childTarget + || targets.some(target => + target.pid === childPid && target.fd === childTarget.fd)) continue; + targets.push({ pid: childPid, ...childTarget }); + } + } - /** - * Listen for a syscall on a channel using Atomics.waitAsync. - * When the process sets status to PENDING, we handle the syscall. - */ - private listenOnChannel(channel: ChannelInfo): void { - // Re-create Int32Array view in case memory was grown - const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); - channel.i32View = i32View; + if (!includeEpoll) return; - const statusIndex = CH_STATUS / 4; + const fdIsOpen = this.kernelInstance!.exports.kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + for (const [key, interests] of Array.from(this.epollInterests.entries())) { + if (!key.startsWith(`${parentPid}:`)) continue; + const epfd = Number(key.slice(key.indexOf(":") + 1)); + if (fdIsOpen && fdIsOpen(childPid, epfd) !== 1) continue; + this.epollInterests.set( + `${childPid}:${epfd}`, + interests + .filter((entry) => !fdIsOpen || fdIsOpen(childPid, entry.fd) === 1) + .map((entry) => ({ ...entry })), + ); + } + } - // Check if already pending (process might have sent before we started listening) - const currentStatus = Atomics.load(i32View, statusIndex); + /** Remove host-only child state after fork/spawn Worker launch fails. */ + private rollbackChildHostRegistration(childPid: number): void { + this.deactivateProcess(childPid); + for (const key of Array.from(this.epollInterests.keys())) { + if (key.startsWith(`${childPid}:`)) this.epollInterests.delete(key); + } + } - if (currentStatus === CH_PENDING) { - // Handle the syscall. In browser mode (relistenBatchSize=1), defer via - // setImmediate so that Atomics.waitAsync microtask resolutions don't - // create tight chains that starve the event loop. In Node.js (default - // batchSize=64), handle immediately for throughput. - if (this.relistenBatchSize <= 1) { - setImmediate(() => { - if (this.processes.has(channel.pid)) { - this.handleSyscall(channel); + /** Reconcile host-only fd mirrors after the kernel closes CLOEXEC fds. */ + private pruneExecFdMirrors( + pid: number, + listenerWakeSnapshot: Map, + ): void { + const fdIsOpen = this.kernelInstance!.exports.kernel_fd_is_open as + ((pid: number, fd: number) => number) | undefined; + if (!fdIsOpen) return; + const open = (fd: number) => fdIsOpen(pid, fd) === 1; + const prefix = `${pid}:`; + const getAcceptWake = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const findListenerFd = this.kernelInstance!.exports + .kernel_find_listener_fd_by_accept_wake as + ((pid: number, wakeIdx: number) => number) | undefined; + const aliasByWake = new Map(); + const resolveListenerFd = (port: number, oldFd: number): number | null => { + const wakeIdx = listenerWakeSnapshot.get(`${port}:${oldFd}`); + if (wakeIdx === undefined || !getAcceptWake) + return open(oldFd) ? oldFd : null; + if (getAcceptWake(pid, oldFd) === wakeIdx) return oldFd; + if (aliasByWake.has(wakeIdx)) return aliasByWake.get(wakeIdx)!; + let candidate = findListenerFd?.(pid, wakeIdx) ?? -1; + // ABI 16 kernels built before the additive resolver export can still + // recover aliases within their historical default descriptor range. + if (!findListenerFd) { + for (let fd = 0; fd < 1024; fd++) { + if (getAcceptWake(pid, fd) === wakeIdx) { + candidate = fd; + break; } - }); + } + } + const alias = candidate >= 0 ? candidate : null; + aliasByWake.set(wakeIdx, alias); + return alias; + }; + + for (const [key, interests] of Array.from(this.epollInterests.entries())) { + if (!key.startsWith(prefix)) continue; + const epfd = Number(key.slice(prefix.length)); + if (!open(epfd)) { + this.epollInterests.delete(key); } else { - this.handleSyscall(channel); + // The current epoll model stores numeric fds rather than OFD identity. + // Dropping closed targets prevents later fd reuse from observing a + // stale registration; duplicate-fd retention remains a documented gap. + this.epollInterests.set(key, interests.filter(entry => open(entry.fd))); } - return; } - // Wait for status to change from its current value. - // After a syscall completes, the process resets status COMPLETE→IDLE, - // then on its next syscall sets IDLE→PENDING. We need to handle all - // transitions, not just IDLE→PENDING. - const waitResult = Atomics.waitAsync(i32View, statusIndex, currentStatus); + for (const [port, targets] of Array.from(this.tcpListenerTargets.entries())) { + const retained: Array<{ pid: number; fd: number; acceptWakeIdx?: number }> = []; + for (const target of targets) { + if (target.pid !== pid) { + retained.push(target); + continue; + } + const fd = resolveListenerFd(port, target.fd); + if (fd !== null && !retained.some(entry => entry.pid === pid && entry.fd === fd)) { + retained.push({ ...target, pid, fd }); + } + } + if (retained.length === 0) { + this.tcpListenerTargets.delete(port); + this.tcpListenerRRIndex.delete(port); + const virtualKey = this.tcpVirtualListenerKeys.get(port); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(port); + } + } else { + this.tcpListenerTargets.set(port, retained); + const oldIndex = this.tcpListenerRRIndex.get(port) ?? 0; + this.tcpListenerRRIndex.set(port, oldIndex % retained.length); + } + } - if (waitResult.async) { - waitResult.value.then(() => { - // Check if still registered - if (!this.processes.has(channel.pid)) return; - // Status changed — re-enter to check new value - this.listenOnChannel(channel); - }); - } else { - // Synchronous result — status already changed from what we expected - // Re-check on next tick to avoid stack overflow from tight loops - this.relistenChannel(channel); + const removedByPort = new Map(); + for (const [key, listener] of Array.from(this.tcpListeners.entries())) { + if (!key.startsWith(prefix)) continue; + const fd = Number(key.slice(prefix.length)); + const replacementFd = resolveListenerFd(listener.port, fd); + if (replacementFd === fd) continue; + this.tcpListeners.delete(key); + if (replacementFd === null) { + removedByPort.set(listener.port, listener); + } else { + const replacementKey = `${pid}:${replacementFd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { ...listener, pid }); + } + } + } + for (const [port, listener] of removedByPort) { + const targets = this.tcpListenerTargets.get(port); + if (!targets || targets.length === 0) { + listener.server.close(); + const virtualKey = this.tcpVirtualListenerKeys.get(port); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(port); + } + } else { + const replacement = targets[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { ...listener, pid: replacement.pid }); + } + } } } + /** Whether a file mapping has a real writable regular-file backing. */ + private fdSupportsMmapWriteback(pid: number, fd: number): boolean { + const supports = this.kernelInstance!.exports + .kernel_fd_supports_mmap_writeback as + ((pid: number, fd: number) => number) | undefined; + // Older ABI-16 kernels predate capability classification. Preserve their + // existing msync behavior; exec itself is feature-gated on newer metadata + // exports, so it cannot hit the old device-preflush failure. + return supports ? supports(pid, fd) === 1 : true; + } + /** - * Handle a pending syscall from a process channel. - * - * 1. Read syscall number + args from process Memory - * 2. For each pointer arg: copy data from process Memory to kernel scratch - * 3. Write adjusted args to kernel scratch channel header - * 4. Call kernel_handle_channel(scratchOffset, pid) - * 5. For each output pointer arg: copy data from kernel scratch to process Memory - * 6. Write return value + errno to process channel - * 7. Set status to COMPLETE and notify process - * 8. Re-listen for next syscall + * Flush mappings owned by the address space that exec is about to discard. + * Tracking and SysV attachments remain intact until the kernel commit + * succeeds, so a failed exec can continue using the old address space. */ - private getKernelMem(): Uint8Array { - const buf = this.kernelMemory!.buffer; - if (buf !== this.cachedKernelBuffer) { - this.cachedKernelMem = new Uint8Array(buf); - this.cachedKernelBuffer = buf; + prepareAddressSpaceForExec(pid: number): number { + const registration = this.processes.get(pid); + const channel = registration?.channels[0]; + if (!channel) { + const hasShared = (this.sharedMappings.get(pid)?.size ?? 0) > 0; + const hasSysv = (this.shmMappings.get(pid)?.size ?? 0) > 0; + return hasShared || hasSysv ? -EIO : 0; + } + + try { + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + const shared = this.sharedMappings.get(pid); + if (shared) { + for (const [addr, mapping] of shared) { + if (!mapping.writable) continue; + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (backing && !this.flushSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + )) return -EIO; + continue; + } + if (mapping.backingKey) continue; + if (!this.pwriteFromProcessMemory( + channel, + mapping.fd, + addr, + mapping.len, + mapping.fileOffset, + )) return -EIO; + } + } + return this.syncSysvShmMappingsFromProcess(channel, { force: true }) ? 0 : -EIO; + } catch { + return -EIO; } - return this.cachedKernelMem!; } - /** Get pointer width for a process (4=wasm32, 8=wasm64). */ - private getPtrWidth(pid: number): 4 | 8 { - return this.processes.get(pid)?.ptrWidth ?? 4; + /** + * Forget mappings and detach SysV segments after the irreversible kernel + * exec commit. A failure here is post-commit and must be treated as fatal by + * the caller; returning to the discarded image is no longer possible. + */ + finalizeAddressSpaceForExec(pid: number): number { + const shared = this.sharedMappings.get(pid); + if (shared) { + for (const mapping of shared.values()) this.releaseSharedMapping(mapping); + this.sharedMappings.delete(pid); + } + this.invalidateSharedMmapFdCacheForPid(pid); + + const sysv = this.shmMappings.get(pid); + if (!sysv) return 0; + const detach = this.kernelInstance!.exports.kernel_ipc_shmdt as + ((shmid: number) => number) | undefined; + let result = 0; + try { + if (!detach) return -EIO; + this.withKernelCurrentPid(pid, () => { + for (const mapping of sysv.values()) { + if (detach(mapping.segId) < 0) result = -EIO; + } + }); + } catch { + result = -EIO; + } finally { + this.shmMappings.delete(pid); + } + return result; } - toKernelPtr(value: number | bigint): KernelPointer { - return this.kernel.toKernelPtr(value); + /** + * Remove old channel/registration state for a process about to exec. + * Does NOT remove from kernel process table (exec keeps the same pid). + * Preserves alarm()/ITIMER_REAL, but cancels timer_create() timers: POSIX + * keeps interval timers across exec and deletes per-process POSIX timers. + */ + prepareProcessForExec(pid: number): void { + const registration = this.processes.get(pid); + (this.execHandoffPids ??= new Set()).add(pid); + if (registration) registration.channels = []; + // The old image's exact mailboxes can never publish after exec. Preserve + // the pid-level stop state: exec changes the image, not process state. + this.discardStoppedChannelStateForProcess(pid, false); + + // Remove channels from active list (stops listening on old memory) + this.activeChannels = this.activeChannels.filter((ch) => ch.pid !== pid); + + // Clean up pending blocking retries (the old program's syscalls are dead) + this.cleanupPendingPollRetries(pid); + this.cleanupPendingSelectRetries(pid); + this.cleanupPendingSignalWaits(pid); + this.cleanupPendingPipeReaders(pid); + this.cleanupPendingPipeWriters(pid); + + // Deferred wait/sleep/futex completions retain the discarded Memory and + // would otherwise be able to run after the same pid is re-registered. + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.parentPid !== pid, + ); + this.cancelPendingSleepsForProcess(pid); + for (const [channel, wait] of this.pendingFutexWaits) { + if (channel.pid !== pid) continue; + this.pendingFutexWaits.delete(channel); + // Release the waitAsync closure so it can observe that this channel is + // stale and drop its completion instead of retaining the old Memory. + try { + if (wait.retire) wait.retire(); + else + Atomics.notify( + new Int32Array(channel.memory.buffer), + wait.futexIndex, + 1, + ); + } catch { + // A detached/invalid discarded buffer needs no further cleanup. + } + } + for (const channel of this.pendingCancels) { + if (channel.pid === pid) this.pendingCancels.delete(channel); + } + + // Thread mailbox identity and fork/clear-TID metadata belong to the old + // image even though exec preserves the process id. + const channelPrefix = `${pid}:`; + for (const key of this.channelTids.keys()) { + if (key.startsWith(channelPrefix)) this.channelTids.delete(key); + } + for (const key of this.threadForkContexts.keys()) { + if (key.startsWith(channelPrefix)) this.threadForkContexts.delete(key); + } + for (const key of this.threadCtidPtrs.keys()) { + if (key.startsWith(channelPrefix)) this.threadCtidPtrs.delete(key); + } + + for (const [key, entry] of this.posixTimers) { + if (key.startsWith(`${pid}:`)) { + clearTimeout(entry.timeout); + if (entry.interval) clearInterval(entry.interval); + this.posixTimers.delete(key); + } + } + for (const [ch, timer] of this.socketTimeoutTimers) { + if (ch.pid === pid) { + clearTimeout(timer); + this.socketTimeoutTimers.delete(ch); + } + } + + // Keep a zero-channel registration until the replacement is installed. + // Network endpoints use process presence as their liveness signal; deleting + // the pid across awaited worker termination would make UDP drop datagrams + // and could permanently evict this owner from a shared TCP listener. } - /** Debug: last N syscalls per pid for crash diagnosis */ - private syscallRing = new Map(); - dumpLastSyscalls(pid: number): string { - return (this.syscallRing.get(pid) ?? []).join("\n"); + /** True while exec has committed but the replacement channel is not installed. */ + isExecHandoffActive(pid: number): boolean { + return this.execHandoffPids?.has(pid) ?? false; } - /** Read a null-terminated C string from process memory */ - private readCString(memory: WebAssembly.Memory, ptr: number, maxLen = 256): string { - if (ptr === 0) return "(null)"; - const mem = new Uint8Array(memory.buffer); - let len = 0; - while (len < maxLen && ptr + len < mem.length && mem[ptr + len] !== 0) len++; - // TextDecoder.decode() rejects views over SharedArrayBuffer in Chrome; - // copy into a non-shared scratch first. - const copy = new Uint8Array(len); - copy.set(mem.subarray(ptr, ptr + len)); - return new TextDecoder().decode(copy); + /** Release the exec guard only after the outer worker generation is installed. */ + finishProcessExecHandoff(pid: number): void { + this.execHandoffPids?.delete(pid); } - private readBytesPreview(memory: WebAssembly.Memory, ptr: number, len: number, maxLen = 160): string { - if (ptr === 0 || len <= 0) return ""; - const mem = new Uint8Array(memory.buffer); - const capped = Math.max(0, Math.min(len, maxLen, mem.length - ptr)); - if (capped <= 0) return ""; - const copy = new Uint8Array(capped); - copy.set(mem.subarray(ptr, ptr + capped)); - return new TextDecoder("utf-8", { fatal: false }).decode(copy); + /** + * Remove a process from the kernel's PROCESS_TABLE. + * Called when a zombie is reaped by wait/waitpid. + */ + removeFromKernelProcessTable(pid: number): void { + const removeProcess = this.kernelInstance!.exports.kernel_remove_process as (pid: number) => number; + removeProcess(pid); } - private formatPollFds(memory: WebAssembly.Memory, ptr: number, nfds: number): string { - if (ptr === 0 || nfds <= 0) return ""; - const view = new DataView(memory.buffer); - const entries: string[] = []; - const capped = Math.min(nfds, 8); - for (let i = 0; i < capped; i++) { - const off = ptr + i * 8; - if (off + 8 > view.byteLength) break; - const fd = view.getInt32(off, true); - const events = view.getInt16(off + 4, true); - const revents = view.getInt16(off + 6, true); - entries.push(`{fd:${fd},events:0x${(events & 0xffff).toString(16)},revents:0x${(revents & 0xffff).toString(16)}}`); + /** + * Add a new channel (e.g. for a thread) to an existing process registration. + * Uses the process's existing memory. If tid is provided, tracks the mapping + * so handleExit can identify thread exits. `threadFnPtr` / `threadArgPtr` + * are stored when the thread was created via clone() so `handleFork` can + * route a fork() from this thread back through its entry point. + */ + addChannel( + pid: number, + channelOffset: number, + tid?: number, + threadFnPtr?: number, + threadArgPtr?: number, + expectedMemory?: WebAssembly.Memory, + ): void { + if (this.execHandoffPids?.has(pid)) { + throw new Error(`Process ${pid} is replacing its image`); + } + if (!this.isProcessExecutionActive(pid)) { + throw new Error(`Process ${pid} is not running`); + } + const registration = this.processes.get(pid); + if (!registration) throw new Error(`Process ${pid} not registered`); + if (expectedMemory && registration.memory !== expectedMemory) { + throw new Error(`Process ${pid} changed memory generation`); } - if (nfds > capped) entries.push("..."); - return entries.join(","); - } - /** Format a syscall for logging, decoding path/string args from process memory */ - private formatSyscallEntry(channel: ChannelInfo, syscallNr: number, args: number[]): string { - const name = SYSCALL_NAMES[syscallNr] ?? `syscall_${syscallNr}`; - const pid = channel.pid; - const tid = this.channelTids.get(`${pid}:${channel.channelOffset}`); - const tidSuffix = tid !== undefined ? `:t${tid}` : ``; + const channel: ChannelInfo = { + pid, + memory: registration.memory, + channelOffset, + i32View: new Int32Array(registration.memory.buffer, channelOffset), + consecutiveSyscalls: 0, + }; - // Decode args based on syscall type - switch (syscallNr) { - case ABI_SYSCALLS.Open: // open(path, flags, mode) - return `[${pid}${tidSuffix}] open("${this.readCString(channel.memory, args[0])}", 0x${(args[1] >>> 0).toString(16)}, 0o${(args[2] >>> 0).toString(8)})`; - case ABI_SYSCALLS.Openat: // openat(dirfd, path, flags, mode) - return `[${pid}${tidSuffix}] openat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[2] >>> 0).toString(16)}, 0o${(args[3] >>> 0).toString(8)})`; - case ABI_SYSCALLS.Stat: // stat(path, buf) - return `[${pid}${tidSuffix}] stat("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Lstat: // lstat(path, buf) - return `[${pid}${tidSuffix}] lstat("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Fstatat: // fstatat(dirfd, path, buf, flags) - return `[${pid}${tidSuffix}] fstatat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[3] >>> 0).toString(16)})`; - case ABI_SYSCALLS.Access: // access(path, mode) - return `[${pid}${tidSuffix}] access("${this.readCString(channel.memory, args[0])}", ${args[1]})`; - case ABI_SYSCALLS.Faccessat: // faccessat(dirfd, path, mode, flags) - return `[${pid}${tidSuffix}] faccessat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[2]})`; - case ABI_SYSCALLS.Chdir: // chdir(path) - return `[${pid}${tidSuffix}] chdir("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Opendir: // opendir(path) - return `[${pid}${tidSuffix}] opendir("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Readlink: // readlink(path, buf, bufsiz) - return `[${pid}${tidSuffix}] readlink("${this.readCString(channel.memory, args[0])}", ${args[2]})`; - case ABI_SYSCALLS.Readlinkat: // readlinkat(dirfd, path, buf, bufsiz) - return `[${pid}${tidSuffix}] readlinkat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[3]})`; - case ABI_SYSCALLS.Realpath: // realpath(path, buf, bufsiz) - return `[${pid}${tidSuffix}] realpath("${this.readCString(channel.memory, args[0])}")`; - case ABI_SYSCALLS.Read: // read(fd, buf, count) - return `[${pid}${tidSuffix}] read(${args[0]}, ${args[2]})`; - case ABI_SYSCALLS.Write: // write(fd, buf, count) - return `[${pid}${tidSuffix}] write(${args[0]}, ${args[2]}, ${JSON.stringify(this.readBytesPreview(channel.memory, args[1], args[2]))})`; - case ABI_SYSCALLS.Close: // close(fd) - return `[${pid}${tidSuffix}] close(${args[0]})`; - case ABI_SYSCALLS.Fstat: // fstat(fd, buf) - return `[${pid}${tidSuffix}] fstat(${args[0]})`; - case ABI_SYSCALLS.Fcntl: // fcntl(fd, cmd, arg) - return `[${pid}${tidSuffix}] fcntl(${args[0]}, ${args[1]}, ${args[2]})`; - case ABI_SYSCALLS.Mmap: // mmap(addr, len, prot, flags, fd, offset) - return `[${pid}${tidSuffix}] mmap(0x${(args[0] >>> 0).toString(16)}, ${args[1] >>> 0}, ${args[2]}, 0x${(args[3] >>> 0).toString(16)}, ${args[4]}, ${args[5] >>> 0})`; - case ABI_SYSCALLS.Munmap: // munmap(addr, len) - return `[${pid}${tidSuffix}] munmap(0x${(args[0] >>> 0).toString(16)}, ${args[1] >>> 0})`; - case ABI_SYSCALLS.Brk: // brk(addr) - return `[${pid}${tidSuffix}] brk(0x${(args[0] >>> 0).toString(16)})`; - case HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE: // execve(path, argv, envp) - return `[${pid}${tidSuffix}] execve("${this.readCString(channel.memory, args[0])}")`; - case HOST_INTERCEPTED_SYSCALLS.SYS_FORK: return `[${pid}${tidSuffix}] fork()`; - case HOST_INTERCEPTED_SYSCALLS.SYS_VFORK: return `[${pid}${tidSuffix}] vfork()`; - case ABI_SYSCALLS.Clone: // clone(flags, stack, ptid, tls, ctid) - return `[${pid}${tidSuffix}] clone(0x${(args[0] >>> 0).toString(16)})`; - case ABI_SYSCALLS.Exit: return `[${pid}${tidSuffix}] exit(${args[0]})`; - case ABI_SYSCALLS.Poll: // poll(fds, nfds, timeout) - return `[${pid}${tidSuffix}] poll(${args[1]}, ${args[2]}, [${this.formatPollFds(channel.memory, args[0], args[1])}])`; - case ABI_SYSCALLS.Ioctl: // ioctl(fd, cmd, arg) - return `[${pid}${tidSuffix}] ioctl(${args[0]}, 0x${(args[1] >>> 0).toString(16)})`; - default: - return `[${pid}${tidSuffix}] ${name}(${args.filter((_, i) => i < 3).join(", ")})`; - } - } + registration.channels.push(channel); + this.activeChannels.push(channel); - /** Format a syscall return value for logging */ - private formatSyscallReturn(syscallNr: number, retVal: number, errVal: number): string { - if (retVal < 0 || errVal !== 0) { - const errName = ERRNO_NAMES[errVal] ?? `errno=${errVal}`; - return ` = ${retVal} (${errName})`; + if (tid !== undefined) { + this.channelTids.set(`${pid}:${channelOffset}`, tid); } - // Format return value based on syscall type - switch (syscallNr) { - case ABI_SYSCALLS.Mmap: // mmap - return ` = 0x${(retVal >>> 0).toString(16)}`; - case ABI_SYSCALLS.Brk: // brk - return ` = 0x${(retVal >>> 0).toString(16)}`; - default: - return ` = ${retVal}`; + if (threadFnPtr !== undefined && threadArgPtr !== undefined) { + this.threadForkContexts.set(`${pid}:${channelOffset}`, { + fnPtr: threadFnPtr, + argPtr: threadArgPtr, + }); } - } - private handleSyscall(channel: ChannelInfo): void { - try { - if (PROFILING) { - const pv = new DataView(channel.memory.buffer, channel.channelOffset); - const nr = pv.getUint32(CH_SYSCALL, true); - const start = performance.now(); - this._handleSyscallInner(channel); - const elapsed = performance.now() - start; - let entry = this.profileData!.get(nr); - if (!entry) { - entry = { count: 0, totalTimeMs: 0, retries: 0 }; - this.profileData!.set(nr, entry); - } - entry.count++; - entry.totalTimeMs += elapsed; - return; + // Lower the kernel's mmap ceiling only for legacy high-address thread + // control pages. Compact process memories reserve thread pages before the + // process's mmap base when the process is registered. + const setMaxAddr = this.kernelInstance!.exports.kernel_set_max_addr as + ((pid: number, maxAddr: KernelPointer) => number) | undefined; + if (setMaxAddr && !registration.explicitMaxAddr) { + const tlsPageAddr = channelOffset - 2 * WASM_PAGE_SIZE; + if (tlsPageAddr >= PROCESS_MMAP_BASE) { + setMaxAddr(pid, this.toKernelPtr(tlsPageAddr)); } - this._handleSyscallInner(channel); - } catch (err) { - console.error(`[handleSyscall] UNCAUGHT ERROR pid=${channel.pid}:`, err); - // Complete channel with EIO to unblock the process - this.completeChannelRaw(channel, -5, 5); - this.relistenChannel(channel); + } + + // In polling mode, the poller picks up new channels automatically. + if (!this.usePolling) { + this.listenOnChannel(channel); } } - private _handleSyscallInner(channel: ChannelInfo): void { - const processView = new DataView(channel.memory.buffer, channel.channelOffset); + /** + * Remove a channel from a process registration (e.g. when a thread exits). + */ + removeChannel(pid: number, channelOffset: number): void { + const registration = this.processes.get(pid); + if (!registration) return; - // Read syscall number and args from process channel - const syscallNr = processView.getUint32(CH_SYSCALL, true); - const origArgs: number[] = []; - for (let i = 0; i < CH_ARGS_COUNT; i++) { - origArgs.push(Number(processView.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true))); + for (const channel of registration.channels) { + if (channel.channelOffset !== channelOffset) continue; + this.retireExactChannelAsyncState(channel); } - // Track last 30 syscalls per channel for crash diagnostics - const ringKey = channel.pid; - let ring = this.syscallRing.get(ringKey); - if (!ring) { ring = []; this.syscallRing.set(ringKey, ring); } - ring.push(` ${this.formatSyscallEntry(channel, syscallNr, origArgs)}`); - if (ring.length > 30) ring.shift(); + registration.channels = registration.channels.filter( + (ch) => ch.channelOffset !== channelOffset, + ); + this.activeChannels = this.activeChannels.filter( + (ch) => !(ch.pid === pid && ch.channelOffset === channelOffset), + ); + this.channelTids.delete(`${pid}:${channelOffset}`); + this.threadForkContexts.delete(`${pid}:${channelOffset}`); + } - // Opt-in live trace ring. enableSyscallTrace() flips the flag; the - // host polls via drainSyscallTrace(). Zero cost when off. - if (this.syscallTraceEnabled) { - if (this.syscallTraceRing.length >= this.syscallTraceCap) { - // Drop the oldest entry; a forgotten subscriber shouldn't blow memory. - this.syscallTraceRing.shift(); + /** + * Retire every host-owned asynchronous continuation for one exact mailbox. + * No guest result is published: the channel generation is being removed. + */ + private retireExactChannelAsyncState(channel: ChannelInfo): void { + this.discardStoppedChannelState(channel); + this.resumePreparedSignals?.delete(channel); + this.pendingCancels?.delete(channel); + this.waitingForChild = (this.waitingForChild ?? []).filter( + (waiter) => waiter.channel !== channel, + ); + + const signalWaitKey = `${channel.pid}:${channel.channelOffset}`; + const signalWait = this.pendingSignalWaits?.get(signalWaitKey); + if (signalWait) clearTimeout(signalWait.timer); + this.pendingSignalWaits?.delete(signalWaitKey); + this.signalWaitDeadlines?.delete(signalWaitKey); + + const sleep = this.pendingSleeps?.get(channel); + if (sleep) clearTimeout(sleep.timer); + this.pendingSleeps?.delete(channel); + + const futex = this.pendingFutexWaits?.get(channel); + if (futex) { + this.pendingFutexWaits.delete(channel); + if (futex.retire) futex.retire(); + else { + try { + Atomics.notify( + new Int32Array(channel.memory.buffer), + futex.futexIndex, + ); + } catch { + // A detached discarded memory has no waiter left to release. + } } - this.syscallTraceRing.push({ - t: performance.now(), - pid: channel.pid, - nr: syscallNr, - args: [ - origArgs[0] ?? 0, origArgs[1] ?? 0, origArgs[2] ?? 0, - origArgs[3] ?? 0, origArgs[4] ?? 0, origArgs[5] ?? 0, - ], - decoded: this.formatSyscallEntry(channel, syscallNr, origArgs), - }); } - // Syscall logging (enable globally via enableSyscallLog, or filter by - // process pointer width via syscallLogPtrWidth — useful when a single - // wasm64 process in a mixed-arch demo needs a focused trace). - const widthFilter = this.config.syscallLogPtrWidth; - const matchesWidthFilter = widthFilter !== undefined - && this.processes.get(channel.pid)?.ptrWidth === widthFilter; - const logging = !!this.config.enableSyscallLog || matchesWidthFilter; - let logEntry = ""; - if (logging) { - logEntry = this.formatSyscallEntry(channel, syscallNr, origArgs); + const poll = this.pendingPollRetries?.get(channel); + if (poll?.timer !== null && poll?.timer !== undefined) { + clearTimeout(poll.timer); + clearImmediate(poll.timer); } + this.pendingPollRetries?.delete(channel); + const select = this.pendingSelectRetries?.get(channel); + if (select?.timer !== null && select?.timer !== undefined) { + clearTimeout(select.timer); + clearImmediate(select.timer); + } + this.pendingSelectRetries?.delete(channel); + channel.readinessDeadline = undefined; + channel.readinessFinalCheck = undefined; - // --- Intercept fork/exec/clone/exit before calling kernel --- - // These syscalls need special async handling that can't go through - // the blocking host_fork/host_exec imports. + this.removePendingPipeReader(channel); + this.removePendingPipeWriter(channel); + const socketTimer = this.socketTimeoutTimers?.get(channel); + if (socketTimer !== undefined) clearTimeout(socketTimer); + this.socketTimeoutTimers?.delete(channel); + } - if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { - if (logging) console.error(logEntry); - this.handleFork(channel, origArgs); - return; + /** Gather even partially detached channel objects before process teardown. */ + private retireAsyncChannelsForProcess(pid: number): void { + const channels = new Set(); + for (const channel of this.processes.get(pid)?.channels ?? []) { + channels.add(channel); } - - if (syscallNr === SYS_SPAWN) { - if (logging) console.error(logEntry); - this.handleSpawn(channel, origArgs); - return; + for (const channel of this.activeChannels ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const waiter of this.waitingForChild ?? []) { + if (waiter.channel.pid === pid) channels.add(waiter.channel); + } + for (const channel of this.pendingSleeps?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingFutexWaits?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingPollRetries?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); } + for (const channel of this.pendingSelectRetries?.keys() ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const channel of this.pendingCancels ?? []) { + if (channel.pid === pid) channels.add(channel); + } + for (const readers of this.pendingPipeReaders?.values() ?? []) { + for (const reader of readers) { + if (reader.channel.pid === pid) channels.add(reader.channel); + } + } + for (const writers of this.pendingPipeWriters?.values() ?? []) { + for (const writer of writers) { + if (writer.channel.pid === pid) channels.add(writer.channel); + } + } + for (const channel of channels) this.retireExactChannelAsyncState(channel); + } - if (syscallNr === SYS_EXECVE) { - if (logging) console.error(logEntry); - this.handleExec(channel, origArgs); - return; + /** + * Return whether this exact channel object belongs to the pid's current + * registration. Exec deliberately reuses the numeric pid (and commonly the + * same channel offset), so pid existence alone cannot distinguish a stale + * waitAsync/timer continuation from the replacement image's channel. + */ + private isRegisteredChannel(channel: ChannelInfo): boolean { + const registration = this.processes.get(channel.pid); + return registration !== undefined + && registration.channels.includes(channel); + } + + /** + * Async continuations may run while an exact channel remains registered for + * orderly worker teardown even though its kernel Process is already dead. + */ + private isAsyncChannelProcessActive(channel: ChannelInfo): boolean { + if (!this.isRegisteredChannel(channel) || this.hostReaped?.has(channel.pid)) { + return false; + } + try { + if (this.getProcessExitSignal(channel.pid) > 0) { + this.handleProcessTerminated(channel); + return false; + } + } catch { + // Older compatible kernels lack the additive exit-signal query; channel + // identity remains the best available liveness evidence there. } + return true; + } - if (syscallNr === SYS_EXECVEAT) { - if (logging) console.error(logEntry); - this.handleExecveat(channel, origArgs); - return; + /** Public liveness guard for async Node/browser worker-entry continuations. */ + isProcessExecutionActive(pid: number): boolean { + if (this.hostReaped?.has(pid)) return false; + try { + // kernel_get_process_exit_signal returns -1 while the Process is live + // (Running or Stopped), 0 for a normal zombie, a positive signal for + // signal death, and a negative errno when the pid no longer exists. + return this.getProcessExitSignal(pid) === -1; + } catch { + return true; } + } - if (syscallNr === SYS_CLONE) { - if (logging) console.error(logEntry); - this.handleClone(channel, origArgs); - return; + /** + * Decide whether an asynchronously created fork/spawn child may receive a + * host Worker. A child killed before registration remains a real, waitable + * kernel zombie; finalize its host-only state without rolling it back. + */ + shouldLaunchPendingChild(pid: number): boolean { + if (this.isProcessExecutionActive(pid)) return true; + this.finalizePendingChildTermination(pid); + return false; + } + + /** + * Start a prepared process/thread Worker only when the authoritative kernel + * Process is runnable. Fork/spawn/exec setup may register memory and return + * to its caller while stopped; the constructor itself is retained here so + * no guest instruction can execute before SIGCONT. `expectedMemory` is the + * generation token that prevents a deferred closure from attaching to a + * later exec image or recycled pid. + */ + startProcessWorkerWhenRunnable( + pid: number, + expectedMemory: WebAssembly.Memory, + start: () => void, + cancel: () => void, + onStartError?: (error: unknown) => boolean, + ): ProcessWorkerStartDisposition { + const registration = this.processes.get(pid); + if (!registration || registration.memory !== expectedMemory) { + cancel(); + return "stale"; } - if (syscallNr === SYS_EXIT || syscallNr === SYS_EXIT_GROUP) { - if (logging) console.error(logEntry); - this.handleExit(channel, syscallNr, origArgs); - return; + const getState = this.kernelInstance!.exports.kernel_get_process_state as ( + pid: number, + ) => number; + const state = getState(pid); + if (state === PROCESS_STATE_EXITED) { + cancel(); + return "dead"; + } + if (state < 0) { + cancel(); + return "stale"; + } + const deferStart = (): ProcessWorkerStartDisposition => { + this.stoppedPids.add(pid); + const entry: DeferredProcessWorkerStart = { + expectedMemory, + start, + cancel, + onStartError, + }; + let entries = this.deferredProcessWorkerStarts.get(pid); + if (!entries) { + entries = new Set(); + this.deferredProcessWorkerStarts.set(pid, entries); + } + entries.add(entry); + return "deferred"; + }; + + if (state === PROCESS_STATE_STOPPED) { + return deferStart(); + } + if (state !== PROCESS_STATE_RUNNING) { + cancel(); + return "stale"; } - if (syscallNr === SYS_WAIT4) { - if (logging) console.error(logEntry); - this.handleWaitpid(channel, origArgs); - return; + // A CONTINUED wake may have arrived while async fork/spawn/exec had no + // registered channel to inspect. Queue this constructor first, then make + // the now-registered generation pass through the same all-thread signal + // barrier before any guest instruction can execute. + if (this.pendingResumePids?.has(pid) || this.stoppedPids?.has(pid)) { + deferStart(); + if (this.resumeStoppedProcess(pid)) return "started"; + // Direct resume preflight can apply a retained default stop and enqueue + // a STOPPED wake outside the ordinary wake-drain call stack (notably an + // exec handoff). Service it now so the parent does not remain asleep. + this.drainAndProcessWakeupEvents(); + const postResumeState = getState(pid); + if (postResumeState === PROCESS_STATE_EXITED) return "dead"; + if (postResumeState < 0) return "stale"; + return "deferred"; } - if (syscallNr === SYS_WAITID) { - if (logging) console.error(logEntry); - this.handleWaitid(channel, origArgs); - return; - } - - // --- Futex: must operate on process memory, not kernel memory --- - // The kernel's host_futex_wake/wait imports use kernel memory, but futex - // addresses are in process memory. Intercept here and handle directly. - if (syscallNr === SYS_FUTEX) { - if (logging) { - // Futex args: (uaddr, op, val, timeout, uaddr2, val3). Decode the op - // to make hung-thread investigations readable. - const FUTEX_OPS: Record = { - 0: "WAIT", 1: "WAKE", 2: "FD", 3: "REQUEUE", 4: "CMP_REQUEUE", - 5: "WAKE_OP", 6: "LOCK_PI", 7: "UNLOCK_PI", 8: "TRYLOCK_PI", - 9: "WAIT_BITSET", 10: "WAKE_BITSET", 11: "WAIT_REQUEUE_PI", - 12: "CMP_REQUEUE_PI", - }; - const FUTEX_PRIVATE_FLAG = 128; - const FUTEX_CLOCK_REALTIME = 256; - const op = origArgs[1] >>> 0; - const cmd = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); - const opName = FUTEX_OPS[cmd] ?? `op${cmd}`; - const flags = (op & FUTEX_PRIVATE_FLAG ? "|PRIVATE" : "") - + (op & FUTEX_CLOCK_REALTIME ? "|REALTIME" : ""); - const tid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`); - const tidSuffix = tid !== undefined ? `:t${tid}` : ``; - console.error(`[${channel.pid}${tidSuffix}] futex(0x${(origArgs[0] >>> 0).toString(16)}, ${opName}${flags}, val=${origArgs[2]})`); - } - this.handleFutex(channel, origArgs); - return; - } - - // --- pthread_cancel wake-up: handled entirely on host side because - // the state we must perturb (futex waitAsync, pipe reader registration, - // poll/select retry timers) lives in TS, not in the kernel wasm. --- - if (syscallNr === SYS_THREAD_CANCEL) { - if (logging) console.error(logEntry); - this.handleThreadCancel(channel, origArgs); - return; - } - - // --- Scatter/gather I/O (writev/readv/pwritev/preadv) --- - // These have nested pointers (iov array → base buffers) that can't be - // handled by the simple ArgDesc system. - if (syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV) { - if (logging) console.error(logEntry); - this.handleWritev(channel, syscallNr, origArgs); - return; - } + // The Process may have continued before its ordinary wake event was + // drained. Without an unregistered-resume barrier, the direct state query + // is authoritative for launch permission. + this.stoppedPids.delete(pid); + start(); + return "started"; + } - if (syscallNr === SYS_READV || syscallNr === SYS_PREADV) { - if (logging) console.error(logEntry); - this.handleReadv(channel, syscallNr, origArgs); - return; - } + /** + * Listen for a syscall on a channel using Atomics.waitAsync. + * When the process sets status to PENDING, we handle the syscall. + */ + private listenOnChannel(channel: ChannelInfo): void { + // A waitAsync continuation from the discarded exec image may run after a + // replacement registration with the same pid has been installed. + if (!this.isRegisteredChannel(channel)) return; + if (this.deferChannelWhileStopped(channel)) return; - // --- Large write/pwrite/read/pread: chunk through scratch buffer --- - // When the data exceeds CH_DATA_SIZE, the ArgDesc path returns a short - // read/write. Programs like InnoDB that write 1MB+ chunks may exhaust - // their retry budget. Handle large I/O by looping on the host side. - if ((syscallNr === SYS_WRITE || syscallNr === SYS_PWRITE) && origArgs[2] > CH_DATA_SIZE) { - this.handleLargeWrite(channel, syscallNr, origArgs); - return; - } - if ((syscallNr === SYS_READ || syscallNr === SYS_PREAD) && origArgs[2] > CH_DATA_SIZE) { - this.handleLargeRead(channel, syscallNr, origArgs); - return; - } + // Re-create Int32Array view in case memory was grown + const i32View = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = i32View; - // --- sendmsg/recvmsg: decompose msghdr from process memory --- - if (syscallNr === SYS_SENDMSG) { - this.handleSendmsg(channel, origArgs); - return; - } - if (syscallNr === SYS_RECVMSG) { - this.handleRecvmsg(channel, origArgs); - return; - } + const statusIndex = CH_STATUS / 4; - // --- ioctl: intercept network interface ioctls (SIOCGIFCONF, SIOCGIFHWADDR) --- - // These require host-side handling because: - // SIOCGIFCONF: struct ifconf contains a pointer to a process-memory buffer - // SIOCGIFHWADDR: returns the virtual MAC address for this kernel instance - if (syscallNr === SYS_IOCTL) { - const request = origArgs[1] >>> 0; - if (request === SIOCGIFCONF) { - this.handleIoctlIfconf(channel, origArgs); - return; - } - if (request === SIOCGIFHWADDR) { - this.handleIoctlIfhwaddr(channel, origArgs); - return; - } - if (request === SIOCGIFADDR) { - this.handleIoctlIfaddr(channel, origArgs); - return; - } - } + // Check if already pending (process might have sent before we started listening) + const currentStatus = Atomics.load(i32View, statusIndex); - // --- fcntl with struct flock pointer --- - // When cmd is a lock operation, arg3 is a pointer to struct flock (32 bytes). - // Handle as inout so the kernel can read/write the flock struct. - if (syscallNr === SYS_FCNTL) { - const cmd = origArgs[1]; - if (cmd === F_GETLK || cmd === F_SETLK || cmd === F_SETLKW || - cmd === F_GETLK64 || cmd === F_SETLK64 || cmd === F_SETLKW64 || - cmd === F_OFD_GETLK || cmd === F_OFD_SETLK || cmd === F_OFD_SETLKW) { - this.handleFcntlLock(channel, origArgs); - return; + if (currentStatus === CH_PENDING) { + // Handle the syscall. In browser mode (relistenBatchSize=1), defer via + // setImmediate so that Atomics.waitAsync microtask resolutions don't + // create tight chains that starve the event loop. In Node.js (default + // batchSize=64), handle immediately for throughput. + if (this.relistenBatchSize <= 1) { + setImmediate(() => { + if (this.isRegisteredChannel(channel)) { + this.handleSyscall(channel); + } + }); + } else { + this.handleSyscall(channel); } - } - - // --- epoll: intercept all epoll syscalls on host side --- - // kernel_handle_channel crashes in Chrome (V8 shared-memory Wasm bug) for - // epoll_pwait. Handle epoll_create1/ctl on the kernel but mirror the - // interest list, and convert epoll_pwait to poll entirely on the host. - if (syscallNr === SYS_EPOLL_CREATE1 || syscallNr === SYS_EPOLL_CREATE) { - this.handleEpollCreate(channel, syscallNr, origArgs); - return; - } - if (syscallNr === SYS_EPOLL_CTL) { - this.handleEpollCtl(channel, origArgs); - return; - } - if (syscallNr === SYS_EPOLL_PWAIT || syscallNr === SYS_EPOLL_WAIT) { - this.handleEpollPwait(channel, syscallNr, origArgs); - return; - } - - // --- SysV IPC: shmat/shmdt need host-side process memory management --- - if (syscallNr === SYS_SHMAT) { - this.handleIpcShmat(channel, origArgs); - return; - } - if (syscallNr === SYS_SHMDT) { - this.handleIpcShmdt(channel, origArgs); - return; - } - // --- SysV IPC: semctl has cmd-dependent arg types (scalar vs pointer) --- - if (syscallNr === SYS_SEMCTL) { - this.handleSemctl(channel, origArgs); return; } - // (POSIX mqueue syscalls 331-336 now go through the normal kernel path) + // Wait for status to change from its current value. + // After a syscall completes, the process resets status COMPLETE→IDLE, + // then on its next syscall sets IDLE→PENDING. We need to handle all + // transitions, not just IDLE→PENDING. + const waitResult = Atomics.waitAsync(i32View, statusIndex, currentStatus); - // --- pselect6: fd_sets (inout) + timeout/sigmask decoding --- - if (syscallNr === SYS_PSELECT6) { - this.handlePselect6(channel, origArgs); - return; + if (waitResult.async) { + waitResult.value.then(() => { + // Check that this exact registration generation is still current. + if (!this.isRegisteredChannel(channel)) return; + // Status changed — re-enter to check new value + this.listenOnChannel(channel); + }); + } else { + // Synchronous result — status already changed from what we expected + // Re-check on next tick to avoid stack overflow from tight loops + this.relistenChannel(channel); } + } - // --- select(2): same shape as pselect6 but with `struct timeval` - // (sec, usec) and no sigmask. musl's select.c routes here on wasm64 - // because `__NR_pselect6_time64` isn't defined for that arch (unlike - // wasm32, which aliases it to __NR_pselect6). Without this intercept, - // sys_select returns EAGAIN when it needs host-managed waiting, and the - // generic blocking-retry has no select-timeout awareness — every - // `select(0,0,0,0,&tv)` (= my_sleep) becomes an infinite loop. That - // surfaced as the wasm64 mariadbd boot hang at - // wait_for_signal_thread_to_end's kill+my_sleep loop. - if (syscallNr === SYS_SELECT) { - this.handleSelect(channel, origArgs); - return; + /** + * Handle a pending syscall from a process channel. + * + * 1. Read syscall number + args from process Memory + * 2. For each pointer arg: copy data from process Memory to kernel scratch + * 3. Write adjusted args to kernel scratch channel header + * 4. Call kernel_handle_channel(scratchOffset, pid) + * 5. For each output pointer arg: copy data from kernel scratch to process Memory + * 6. Write return value + errno to process channel + * 7. Set status to COMPLETE and notify process + * 8. Re-listen for next syscall + */ + private getKernelMem(): Uint8Array { + const buf = this.kernelMemory!.buffer; + if (buf !== this.cachedKernelBuffer) { + this.cachedKernelMem = new Uint8Array(buf); + this.cachedKernelBuffer = buf; } + return this.cachedKernelMem!; + } - // --- Normal syscall path --- - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + /** Get pointer width for a process (4=wasm32, 8=wasm64). */ + private getPtrWidth(pid: number): 4 | 8 { + return this.processes.get(pid)?.ptrWidth ?? 4; + } - // Copy raw args to kernel scratch header (will be adjusted below) - const adjustedArgs = [...origArgs]; + toKernelPtr(value: number | bigint): KernelPointer { + return this.kernel.toKernelPtr(value); + } - // Process pointer args: copy data between process and kernel memory - const argDescs = SYSCALL_ARGS[syscallNr]; - let dataOffset = 0; // Offset within scratch data area for allocations + /** Debug: last N syscalls per pid for crash diagnosis */ + private syscallRing = new Map(); + dumpLastSyscalls(pid: number): string { + return (this.syscallRing.get(pid) ?? []).join("\n"); + } - if (argDescs) { - // Re-create typed views (memory may have grown) - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const dataStart = this.scratchOffset + CH_DATA; + /** Read a null-terminated C string from process memory */ + private readCString(memory: WebAssembly.Memory, ptr: number, maxLen = 256): string { + if (ptr === 0) return "(null)"; + const mem = new Uint8Array(memory.buffer); + let len = 0; + while (len < maxLen && ptr + len < mem.length && mem[ptr + len] !== 0) len++; + // TextDecoder.decode() rejects views over SharedArrayBuffer in Chrome; + // copy into a non-shared scratch first. + const copy = new Uint8Array(len); + copy.set(mem.subarray(ptr, ptr + len)); + return new TextDecoder().decode(copy); + } - for (const desc of argDescs) { - const ptr = origArgs[desc.argIndex]; - if (ptr === 0) continue; // null pointer, skip + private readBytesPreview(memory: WebAssembly.Memory, ptr: number, len: number, maxLen = 160): string { + if (ptr === 0 || len <= 0) return ""; + const mem = new Uint8Array(memory.buffer); + const capped = Math.max(0, Math.min(len, maxLen, mem.length - ptr)); + if (capped <= 0) return ""; + const copy = new Uint8Array(capped); + copy.set(mem.subarray(ptr, ptr + capped)); + return new TextDecoder("utf-8", { fatal: false }).decode(copy); + } - // Compute size of data to copy - let size: number; - if (desc.size.type === "cstring") { - // Read null-terminated string length from process memory - let len = 0; - while (processMem[ptr + len] !== 0 && len < CH_DATA_SIZE - dataOffset - 1) { - len++; - } - size = len + 1; // include null terminator - } else if (desc.size.type === "arg") { - size = - origArgs[desc.size.argIndex] * (desc.size.multiplier ?? 1) - + (desc.size.add ?? 0); - } else if (desc.size.type === "deref") { - // Dereference: arg is a pointer to a u32 value (e.g. socklen_t*) - const derefPtr = origArgs[desc.size.argIndex]; - if (derefPtr === 0) continue; - size = processMem[derefPtr] | (processMem[derefPtr + 1] << 8) - | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); - } else { - size = desc.size.size; - } - - if (size <= 0) continue; - - // Cap size to fit in the channel data buffer. For read/write-like - // syscalls where the size comes from another arg, also update that - // arg so the kernel uses the capped count. The caller (musl libc) - // will see a short read/write and retry for the remainder. - if (dataOffset + size > CH_DATA_SIZE) { - size = CH_DATA_SIZE - dataOffset; - if (size <= 0) continue; - if (desc.size.type === "arg") { - adjustedArgs[desc.size.argIndex] = size; - } - } - - const kernelPtr = dataStart + dataOffset; - - // Copy input data from process to kernel - if (desc.direction === "in" || desc.direction === "inout") { - kernelMem.set(processMem.subarray(ptr, ptr + size), kernelPtr); - } else { - // Output-only: zero the kernel scratch area - kernelMem.fill(0, kernelPtr, kernelPtr + size); - } + private formatPollFds(memory: WebAssembly.Memory, ptr: number, nfds: number): string { + if (ptr === 0 || nfds <= 0) return ""; + const view = new DataView(memory.buffer); + const entries: string[] = []; + const capped = Math.min(nfds, 8); + for (let i = 0; i < capped; i++) { + const off = ptr + i * 8; + if (off + 8 > view.byteLength) break; + const fd = view.getInt32(off, true); + const events = view.getInt16(off + 4, true); + const revents = view.getInt16(off + 6, true); + entries.push(`{fd:${fd},events:0x${(events & 0xffff).toString(16)},revents:0x${(revents & 0xffff).toString(16)}}`); + } + if (nfds > capped) entries.push("..."); + return entries.join(","); + } - // Update arg to point to kernel memory - adjustedArgs[desc.argIndex] = kernelPtr; + /** Format a syscall for logging, decoding path/string args from process memory */ + private formatSyscallEntry(channel: ChannelInfo, syscallNr: number, args: number[]): string { + const name = SYSCALL_NAMES[syscallNr] ?? `syscall_${syscallNr}`; + const pid = channel.pid; + const tid = this.channelTids.get(`${pid}:${channel.channelOffset}`); + const tidSuffix = tid !== undefined ? `:t${tid}` : ``; - dataOffset += size; - // Align to 4 bytes for next allocation - dataOffset = (dataOffset + 3) & ~3; - } + // Decode args based on syscall type + switch (syscallNr) { + case ABI_SYSCALLS.Open: // open(path, flags, mode) + return `[${pid}${tidSuffix}] open("${this.readCString(channel.memory, args[0])}", 0x${(args[1] >>> 0).toString(16)}, 0o${(args[2] >>> 0).toString(8)})`; + case ABI_SYSCALLS.Openat: // openat(dirfd, path, flags, mode) + return `[${pid}${tidSuffix}] openat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[2] >>> 0).toString(16)}, 0o${(args[3] >>> 0).toString(8)})`; + case ABI_SYSCALLS.Stat: // stat(path, buf) + return `[${pid}${tidSuffix}] stat("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Lstat: // lstat(path, buf) + return `[${pid}${tidSuffix}] lstat("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Fstatat: // fstatat(dirfd, path, buf, flags) + return `[${pid}${tidSuffix}] fstatat(${args[0]}, "${this.readCString(channel.memory, args[1])}", 0x${(args[3] >>> 0).toString(16)})`; + case ABI_SYSCALLS.Access: // access(path, mode) + return `[${pid}${tidSuffix}] access("${this.readCString(channel.memory, args[0])}", ${args[1]})`; + case ABI_SYSCALLS.Faccessat: // faccessat(dirfd, path, mode, flags) + return `[${pid}${tidSuffix}] faccessat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[2]})`; + case ABI_SYSCALLS.Chdir: // chdir(path) + return `[${pid}${tidSuffix}] chdir("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Opendir: // opendir(path) + return `[${pid}${tidSuffix}] opendir("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Readlink: // readlink(path, buf, bufsiz) + return `[${pid}${tidSuffix}] readlink("${this.readCString(channel.memory, args[0])}", ${args[2]})`; + case ABI_SYSCALLS.Readlinkat: // readlinkat(dirfd, path, buf, bufsiz) + return `[${pid}${tidSuffix}] readlinkat(${args[0]}, "${this.readCString(channel.memory, args[1])}", ${args[3]})`; + case ABI_SYSCALLS.Realpath: // realpath(path, buf, bufsiz) + return `[${pid}${tidSuffix}] realpath("${this.readCString(channel.memory, args[0])}")`; + case ABI_SYSCALLS.Read: // read(fd, buf, count) + return `[${pid}${tidSuffix}] read(${args[0]}, ${args[2]})`; + case ABI_SYSCALLS.Write: // write(fd, buf, count) + return `[${pid}${tidSuffix}] write(${args[0]}, ${args[2]}, ${JSON.stringify(this.readBytesPreview(channel.memory, args[1], args[2]))})`; + case ABI_SYSCALLS.Close: // close(fd) + return `[${pid}${tidSuffix}] close(${args[0]})`; + case ABI_SYSCALLS.Fstat: // fstat(fd, buf) + return `[${pid}${tidSuffix}] fstat(${args[0]})`; + case ABI_SYSCALLS.Fcntl: // fcntl(fd, cmd, arg) + return `[${pid}${tidSuffix}] fcntl(${args[0]}, ${args[1]}, ${args[2]})`; + case ABI_SYSCALLS.Mmap: // mmap(addr, len, prot, flags, fd, offset) + return `[${pid}${tidSuffix}] mmap(0x${(args[0] >>> 0).toString(16)}, ${args[1] >>> 0}, ${args[2]}, 0x${(args[3] >>> 0).toString(16)}, ${args[4]}, ${args[5] >>> 0})`; + case ABI_SYSCALLS.Munmap: // munmap(addr, len) + return `[${pid}${tidSuffix}] munmap(0x${(args[0] >>> 0).toString(16)}, ${args[1] >>> 0})`; + case ABI_SYSCALLS.Brk: // brk(addr) + return `[${pid}${tidSuffix}] brk(0x${(args[0] >>> 0).toString(16)})`; + case HOST_INTERCEPTED_SYSCALLS.SYS_EXECVE: // execve(path, argv, envp) + return `[${pid}${tidSuffix}] execve("${this.readCString(channel.memory, args[0])}")`; + case HOST_INTERCEPTED_SYSCALLS.SYS_FORK: return `[${pid}${tidSuffix}] fork()`; + case HOST_INTERCEPTED_SYSCALLS.SYS_VFORK: return `[${pid}${tidSuffix}] vfork()`; + case ABI_SYSCALLS.Clone: // clone(flags, stack, ptid, tls, ctid) + return `[${pid}${tidSuffix}] clone(0x${(args[0] >>> 0).toString(16)})`; + case ABI_SYSCALLS.Exit: return `[${pid}${tidSuffix}] exit(${args[0]})`; + case ABI_SYSCALLS.Poll: // poll(fds, nfds, timeout) + return `[${pid}${tidSuffix}] poll(${args[1]}, ${args[2]}, [${this.formatPollFds(channel.memory, args[0], args[1])}])`; + case ABI_SYSCALLS.Ioctl: // ioctl(fd, cmd, arg) + return `[${pid}${tidSuffix}] ioctl(${args[0]}, 0x${(args[1] >>> 0).toString(16)})`; + default: + return `[${pid}${tidSuffix}] ${name}(${args.filter((_, i) => i < 3).join(", ")})`; } + } - // ppoll: convert timespec pointer and sigset pointer to scalar values. - // musl sends: (fds, nfds, timespec_ptr, sigset_ptr, sigset_size) - // kernel expects: (fds, nfds, timeout_ms, has_mask, mask_lo, mask_hi) - if (syscallNr === SYS_PPOLL) { - const tsPtr = origArgs[2]; - if (tsPtr !== 0) { - // time64: timespec is {int64 sec, int64 nsec} = 16 bytes - const pv = new DataView(channel.memory.buffer, tsPtr); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - adjustedArgs[2] = sec * 1000 + Math.floor(nsec / 1000000); - } else { - adjustedArgs[2] = -1; // infinite timeout - } - const maskPtr = origArgs[3]; - if (maskPtr !== 0) { - const pv = new DataView(channel.memory.buffer, maskPtr); - adjustedArgs[3] = 1; // has_mask = true - adjustedArgs[4] = pv.getUint32(0, true); // mask_lo - adjustedArgs[5] = pv.getUint32(4, true); // mask_hi - } else { - adjustedArgs[3] = 0; // has_mask = false - adjustedArgs[4] = 0; - adjustedArgs[5] = 0; - } + /** Format a syscall return value for logging */ + private formatSyscallReturn(syscallNr: number, retVal: number, errVal: number): string { + if (retVal < 0 || errVal !== 0) { + const errName = ERRNO_NAMES[errVal] ?? `errno=${errVal}`; + return ` = ${retVal} (${errName})`; } - - // Write adjusted args to kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(adjustedArgs[i]), true); + // Format return value based on syscall type + switch (syscallNr) { + case ABI_SYSCALLS.Mmap: // mmap + return ` = 0x${(retVal >>> 0).toString(16)}`; + case ABI_SYSCALLS.Brk: // brk + return ` = 0x${(retVal >>> 0).toString(16)}`; + default: + return ` = ${retVal}`; } + } - // Call kernel_handle_channel - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - // DIAGNOSTIC: globalThis.__sysprof aggregates per-(pid,syscall_nr) - // timing across kernel_handle_channel calls so we can dump a profile - // afterward (via globalThis.__sysprofDump()). Off by default — flip on - // from the demo page right before the slow operation, off after. - // Also tracks wall-clock gap since *this* pid's previous syscall — that - // gap is the time the pid spent in user wasm code, the actual perf - // bottleneck when kernel-side handling itself is fast. - const sysprof = (globalThis as { __sysprof?: boolean }).__sysprof; - const sysprofStart = sysprof ? performance.now() : 0; - if (sysprof) { - type GapRow = { count: number; gapTotalMs: number; gapMaxMs: number }; - const g = globalThis as { __sysprofGap?: Map; __sysprofLastSeen?: Map }; - if (!g.__sysprofGap) g.__sysprofGap = new Map(); - if (!g.__sysprofLastSeen) g.__sysprofLastSeen = new Map(); - const last = g.__sysprofLastSeen.get(channel.pid); - if (last !== undefined) { - const gap = sysprofStart - last; - let row = g.__sysprofGap.get(channel.pid); - if (!row) { row = { count: 0, gapTotalMs: 0, gapMaxMs: 0 }; g.__sysprofGap.set(channel.pid, row); } - row.count++; - row.gapTotalMs += gap; - if (gap > row.gapMaxMs) row.gapMaxMs = gap; - } - g.__sysprofLastSeen.set(channel.pid, sysprofStart); - } + private handleSyscall(channel: ChannelInfo): void { + if (!this.isRegisteredChannel(channel)) return; + if (this.deferChannelWhileStopped(channel)) return; try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch (err) { - // If the kernel throws (e.g., invalid memory access), complete the - // channel with -EIO to unblock the process rather than deadlocking. - if (logging) console.error(logEntry + " = KERNEL THROW"); - console.error(`[handleSyscall] kernel threw for pid=${channel.pid} syscall=${syscallNr} args=[${origArgs}]:`, err); - this.completeChannelRaw(channel, -5, 5); // -EIO - this.relistenChannel(channel); - return; - } finally { - this.currentHandlePid = 0; - if (sysprof) { - const elapsed = performance.now() - sysprofStart; - type ProfRow = { count: number; totalMs: number; maxMs: number }; - const g = globalThis as { __sysprofTable?: Map }; - if (!g.__sysprofTable) g.__sysprofTable = new Map(); - const key = `${channel.pid}:${syscallNr}`; - let row = g.__sysprofTable.get(key); - if (!row) { row = { count: 0, totalMs: 0, maxMs: 0 }; g.__sysprofTable.set(key, row); } - row.count++; - row.totalMs += elapsed; - if (elapsed > row.maxMs) row.maxMs = elapsed; - if (elapsed > 50) { - console.warn(`[sysprof] slow pid=${channel.pid} nr=${syscallNr} ${elapsed.toFixed(1)}ms args=[${origArgs.join(',')}]`); + if (PROFILING) { + const pv = new DataView(channel.memory.buffer, channel.channelOffset); + const nr = pv.getUint32(CH_SYSCALL, true); + const start = performance.now(); + this._handleSyscallInner(channel); + const elapsed = performance.now() - start; + let entry = this.profileData!.get(nr); + if (!entry) { + entry = { count: 0, totalTimeMs: 0, retries: 0 }; + this.profileData!.set(nr, entry); } + entry.count++; + entry.totalTimeMs += elapsed; + return; } + this._handleSyscallInner(channel); + } catch (err) { + console.error(`[handleSyscall] UNCAUGHT ERROR pid=${channel.pid}:`, err); + // Complete with EIO without re-entering the coherence path that just + // failed. Retrying a persistently unreadable backing here would throw a + // second time and leave the guest channel parked forever. + this.completeChannelRaw(channel, -EIO, EIO); + this.relistenChannel(channel); } + } - // Read return value and errno from kernel scratch - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + private _handleSyscallInner(channel: ChannelInfo): void { + const processView = new DataView(channel.memory.buffer, channel.channelOffset); - // --- Process memory growth for brk/mmap/mremap --- - // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory, not - // the process's. We must grow the process's - // WebAssembly.Memory here so the process can access the new addresses. - if (retVal > 0) { - this.ensureProcessMemoryCovers(channel.pid, channel.memory, syscallNr, retVal, origArgs); - } - - // --- DEBUG: detect memory operations in legacy high control pages --- - const highControlFloor = this.highControlFloorForProcess(channel.pid); - if (syscallNr === SYS_MMAP && retVal > 0 && (retVal >>> 0) !== 0xffffffff) { - const mmapAddr = retVal >>> 0; - const mmapLen = origArgs[1] >>> 0; - if (highControlFloor !== null && mmapAddr + mmapLen > highControlFloor) { - console.error(`[MMAP ALERT] pid=${channel.pid} mmap returned 0x${mmapAddr.toString(16)} len=${mmapLen} — OVERLAPS THREAD REGION! args=[${origArgs.map(a => '0x' + (a >>> 0).toString(16)).join(',')}]`); - } - } - if (syscallNr === SYS_MREMAP && retVal > 0 && (retVal >>> 0) !== 0xffffffff) { - const mremapAddr = retVal >>> 0; - const mremapLen = origArgs[2] >>> 0; - if (highControlFloor !== null && mremapAddr + mremapLen > highControlFloor) { - console.error(`[MREMAP ALERT] pid=${channel.pid} mremap returned 0x${mremapAddr.toString(16)} len=${mremapLen} — OVERLAPS THREAD REGION!`); - } - } - if (highControlFloor !== null && syscallNr === SYS_BRK && retVal > highControlFloor) { - console.error(`[BRK ALERT] pid=${channel.pid} brk returned 0x${(retVal >>> 0).toString(16)} — IN THREAD REGION!`); - } - - // --- File-backed mmap: populate mapped region with file data --- - if (syscallNr === SYS_MMAP && retVal > 0 && (retVal >>> 0) !== 0xffffffff) { - const mmapFd = origArgs[4]; - const mmapFlags = origArgs[3] >>> 0; - if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { - this.populateMmapFromFile(channel, retVal >>> 0, origArgs); - // Track MAP_SHARED file-backed mappings for msync writeback - if (mmapFlags & MAP_SHARED) { - const pageOffset = origArgs[5] >>> 0; - let pidMap = this.sharedMappings.get(channel.pid); - if (!pidMap) { - pidMap = new Map(); - this.sharedMappings.set(channel.pid, pidMap); - } - pidMap.set(retVal >>> 0, { - fd: mmapFd, - fileOffset: pageOffset * 4096, - len: origArgs[1] >>> 0, - }); - } - } - // DRI bo mmap prime: the kernel's sys_mmap on /dev/dri/{render,card} - // already called `host_gbm_bo_bind` to record metadata, but the - // actual SAB→Memory copy is deferred until here so the - // anonymous-mmap zero-fill is in place first. This is what - // delivers the parent's writes to a child across PRIME - // export → fork → PRIME import. No-op for non-DRI mmaps. - const mmapAddr = retVal >>> 0; - const boId = this.kernel.bos.findBindingByAddr(channel.pid, mmapAddr); - if (boId !== undefined) { - this.kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); + // Read syscall number and args from process channel + const syscallNr = processView.getUint32(CH_SYSCALL, true); + const origArgs: number[] = []; + for (let i = 0; i < CH_ARGS_COUNT; i++) { + const rawArg = processView.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true); + // Linux declares sched_getaffinity's length as unsigned int even for a + // 64-bit caller. Normalize it while it is still a bigint, before a + // memory64 value can lose precision in a JavaScript number. + if (syscallNr === SYS_SCHED_GETAFFINITY && i === 1) { + origArgs.push(Number(BigInt.asUintN(32, rawArg))); + } else { + origArgs.push(Number(rawArg)); } } - // --- msync: flush MAP_SHARED regions back to file --- - if (syscallNr === SYS_MSYNC && retVal === 0) { - this.flushSharedMappings(channel, origArgs); - } - - // --- munmap: flush + clean up shared mapping tracking --- - if (syscallNr === SYS_MUNMAP && retVal === 0) { - this.flushSharedMappings(channel, origArgs); - this.cleanupSharedMappings(channel.pid, origArgs[0] >>> 0, origArgs[1] >>> 0); - } + // Track last 30 syscalls per channel for crash diagnostics + const ringKey = channel.pid; + let ring = this.syscallRing.get(ringKey); + if (!ring) { ring = []; this.syscallRing.set(ringKey, ring); } + ring.push(` ${this.formatSyscallEntry(channel, syscallNr, origArgs)}`); + if (ring.length > 30) ring.shift(); - // --- Signal-death check --- - // If deliver_pending_signals marked this process as Exited (e.g., abort() - // raises SIGABRT with default action Terminate), don't complete the channel. - // Instead, record signal-death wait status and terminate the worker. - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - if (getExitStatus) { - const exitStatus = getExitStatus(channel.pid); - if (exitStatus >= 128) { - this.handleProcessTerminated(channel); - return; + // Opt-in live trace ring. enableSyscallTrace() flips the flag; the + // host polls via drainSyscallTrace(). Zero cost when off. + if (this.syscallTraceEnabled) { + if (this.syscallTraceRing.length >= this.syscallTraceCap) { + // Drop the oldest entry; a forgotten subscriber shouldn't blow memory. + this.syscallTraceRing.shift(); } + this.syscallTraceRing.push({ + t: performance.now(), + pid: channel.pid, + nr: syscallNr, + args: [ + origArgs[0] ?? 0, origArgs[1] ?? 0, origArgs[2] ?? 0, + origArgs[3] ?? 0, origArgs[4] ?? 0, origArgs[5] ?? 0, + ], + decoded: this.formatSyscallEntry(channel, syscallNr, origArgs), + }); } - // --- POSIX mqueue notification --- - // After mq_timedsend, the kernel may have a pending notification (signal - // to deliver when a message arrives on a previously empty queue). - if (syscallNr === SYS_MQ_TIMEDSEND && retVal === 0) { - this.drainMqueueNotification(); - } - - // --- Signal delivery --- - // After each syscall, check if the kernel has a pending Handler signal. - // If so, dequeue it and write delivery info to the process channel. - // The glue code (channel_syscall.c) will invoke the handler after waking. - this.dequeueSignalForDelivery(channel); - - // --- Blocking syscall handling --- - // 1. EAGAIN: kernel returned EAGAIN for a blocking syscall. - // Schedule async retry — the process stays blocked on Atomics.wait. - if (retVal === -1 && errVal === EAGAIN) { - if (logging) { - console.error(logEntry + " = -1 (EAGAIN, will retry)"); - } - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; + // Syscall logging (enable globally via enableSyscallLog, or filter by + // process pointer width via syscallLogPtrWidth — useful when a single + // wasm64 process in a mixed-arch demo needs a focused trace). + const widthFilter = this.config.syscallLogPtrWidth; + const matchesWidthFilter = widthFilter !== undefined + && this.processes.get(channel.pid)?.ptrWidth === widthFilter; + const logging = !!this.config.enableSyscallLog || matchesWidthFilter; + let logEntry = ""; + if (logging) { + logEntry = this.formatSyscallEntry(channel, syscallNr, origArgs); } - // 2. Sleep syscalls: kernel returned success immediately, but we need - // to delay the response to simulate the sleep duration. - if (this.handleSleepDelay(channel, syscallNr, origArgs, retVal, errVal)) { + // Separate Wasm memories cannot observe MAP_SHARED/SysV writes directly. + // Treat every guest→kernel transition as a coherence boundary: merge only + // bytes changed since this process's snapshot, then import peer updates. + this.synchronizeSharedMemoryForBoundary(channel); + const mayFlushSharedBacking = (this.sharedMmapBackings?.size ?? 0) > 0; + const flushedSharedBacking = !mayFlushSharedBacking + || this.flushSharedMappingsBeforeFileSyscall(channel, syscallNr, origArgs); + if (mayFlushSharedBacking && this.hostReaped?.has(channel.pid)) return; + if (!flushedSharedBacking) { + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, EIO); return; } + if ( + syscallNr === SYS_MPROTECT + && (origArgs[2] & PROT_WRITE) !== 0 + ) { + const protectionError = this.prepareFileSharedMappingsForWrite( + channel.pid, + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ); + if (protectionError !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + protectionError, + ); + return; + } + } + + // --- Intercept fork/exec/clone/exit before calling kernel --- + // These syscalls need special async handling that can't go through + // the blocking host_fork/host_exec imports. - // --- Process group change: re-check deferred waitpid calls --- - // When a process changes its pgid (setpgid/setsid), a parent blocked in - // waitpid(-pgid) may no longer have any matching children. Wake it with ECHILD. - if (errVal === 0 && (syscallNr === SYS_SETPGID || syscallNr === SYS_SETSID)) { - this.recheckDeferredWaitpids(); + if (syscallNr === SYS_FORK || syscallNr === SYS_VFORK) { + if (logging) console.error(logEntry); + this.handleFork(channel, origArgs); + return; } - // --- Cross-process signal delivery: wake blocked peers + reap kills --- - // When a guest calls kill() on another process (or a process group), - // kernel_kill may invoke deliver_pending_signals on each target. If the - // default action is Terminate (e.g., SIGTERM with no handler), the target - // is marked Exited in its Process struct. - // - // Two follow-ups are required: - // (a) Wake any blocked syscalls on the target (pipe/poll/select) so - // their handlers observe the new exit state and complete with the - // right errno (handled by scheduleWakeBlockedRetries). - // (b) For any process the kernel marked Exited but that is still - // blocked in a non-blocking-retry path (most importantly - // pendingSleeps), call handleProcessTerminated directly so the - // parent's wait4 actually sees the killed child. Without this, - // a `kill` of a sleeping child can leave the parent blocked even - // though Rust has marked the child as an Exited zombie. - if (errVal === 0 && syscallNr === SYS_KILL) { - this.scheduleWakeBlockedRetries(); - this.reapKilledProcessesAfterSyscall(); + if (syscallNr === SYS_SPAWN) { + if (logging) console.error(logEntry); + this.handleSpawn(channel, origArgs); + return; } + if (syscallNr === SYS_EXECVE) { + if (logging) console.error(logEntry); + this.handleExec(channel, origArgs); + return; + } - // --- Normal completion --- - if (logging) { - console.error(logEntry + this.formatSyscallReturn(syscallNr, retVal, errVal)); + if (syscallNr === SYS_EXECVEAT) { + if (logging) console.error(logEntry); + this.handleExecveat(channel, origArgs); + return; } - this.completeChannel(channel, syscallNr, origArgs, argDescs, retVal, errVal); - } - /** - * Dequeue one pending Handler signal from the kernel and write delivery - * info to the process channel. The glue code (channel_syscall.c) reads - * this after the syscall returns and invokes the handler. - */ - private dequeueSignalForDelivery(channel: ChannelInfo): void { - const dequeueSignal = this.kernelInstance!.exports - .kernel_dequeue_signal as ((pid: number, outPtr: KernelPointer) => number) | undefined; - if (!dequeueSignal) return; + if (syscallNr === SYS_CLONE) { + if (logging) console.error(logEntry); + this.handleClone(channel, origArgs); + return; + } - // Use the signal area in kernel scratch as the output buffer - const sigOutOffset = this.scratchOffset + CH_SIG_BASE; - const sigResult = dequeueSignal(channel.pid, this.toKernelPtr(sigOutOffset)); - if (sigResult > 0) { - // Copy 44 bytes of signal delivery info from kernel scratch to process channel - // Layout: signum(4) + handler(4) + flags(4) + si_value(4) + old_mask(8) - // + si_code(4) + si_pid(4) + si_uid(4) + alt_sp(4) + alt_size(4) = 44 bytes - const kernelMem = this.getKernelMem(); - const processMem = new Uint8Array(channel.memory.buffer); - processMem.set( - kernelMem.subarray(sigOutOffset, sigOutOffset + 44), - channel.channelOffset + CH_SIG_BASE, - ); - } else { - // Clear entire signal delivery area in process channel (48 bytes) - const sigStart = channel.channelOffset + CH_SIG_BASE; - new Uint8Array(channel.memory.buffer, sigStart, 48).fill(0); + if (syscallNr === SYS_EXIT || syscallNr === SYS_EXIT_GROUP) { + if (logging) console.error(logEntry); + this.handleExit(channel, syscallNr, origArgs); + return; } - } - /** - * Complete a syscall by copying output data and notifying the process. - */ - private completeChannel( - channel: ChannelInfo, - syscallNr: number, - origArgs: number[], - argDescs: SyscallArgDesc[] | undefined, - retVal: number, - errVal: number, - ): void { - const processView = new DataView(channel.memory.buffer, channel.channelOffset); + if (syscallNr === SYS_WAIT4) { + if (logging) console.error(logEntry); + this.handleWaitpid(channel, origArgs); + return; + } - // Copy output data from kernel scratch back to process memory - if (argDescs) { - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const dataStart = this.scratchOffset + CH_DATA; - let outOffset = 0; + if (syscallNr === SYS_WAITID) { + if (logging) console.error(logEntry); + this.handleWaitid(channel, origArgs); + return; + } - for (const desc of argDescs) { - const origPtr = origArgs[desc.argIndex]; - if (origPtr === 0) continue; + // --- Futex: must operate on process memory, not kernel memory --- + // The kernel's host_futex_wake/wait imports use kernel memory, but futex + // addresses are in process memory. Intercept here and handle directly. + if (syscallNr === SYS_FUTEX) { + if (logging) { + // Futex args: (uaddr, op, val, timeout, uaddr2, val3). Decode the op + // to make hung-thread investigations readable. + const FUTEX_OPS: Record = { + 0: "WAIT", 1: "WAKE", 2: "FD", 3: "REQUEUE", 4: "CMP_REQUEUE", + 5: "WAKE_OP", 6: "LOCK_PI", 7: "UNLOCK_PI", 8: "TRYLOCK_PI", + 9: "WAIT_BITSET", 10: "WAKE_BITSET", 11: "WAIT_REQUEUE_PI", + 12: "CMP_REQUEUE_PI", + }; + const FUTEX_PRIVATE_FLAG = 128; + const FUTEX_CLOCK_REALTIME = 256; + const op = origArgs[1] >>> 0; + const cmd = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); + const opName = FUTEX_OPS[cmd] ?? `op${cmd}`; + const flags = (op & FUTEX_PRIVATE_FLAG ? "|PRIVATE" : "") + + (op & FUTEX_CLOCK_REALTIME ? "|REALTIME" : ""); + const tid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`); + const tidSuffix = tid !== undefined ? `:t${tid}` : ``; + console.error(`[${channel.pid}${tidSuffix}] futex(0x${(origArgs[0] >>> 0).toString(16)}, ${opName}${flags}, val=${origArgs[2]})`); + } + this.handleFutex(channel, origArgs); + return; + } - // Recompute size (same logic as prepareInputData) - let size: number; - if (desc.size.type === "cstring") { - let len = 0; - while (processMem[origPtr + len] !== 0 && len < CH_DATA_SIZE - outOffset - 1) { - len++; - } - size = len + 1; - } else if (desc.size.type === "arg") { - size = - origArgs[desc.size.argIndex] * (desc.size.multiplier ?? 1) - + (desc.size.add ?? 0); - } else if (desc.size.type === "deref") { - const derefPtr = origArgs[desc.size.argIndex]; - if (derefPtr === 0) continue; - size = processMem[derefPtr] | (processMem[derefPtr + 1] << 8) - | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); - } else { - size = desc.size.size; - } + // --- pthread_cancel wake-up: handled entirely on host side because + // the state we must perturb (futex waitAsync, pipe reader registration, + // poll/select retry timers) lives in TS, not in the kernel wasm. --- + if (syscallNr === SYS_THREAD_CANCEL) { + if (logging) console.error(logEntry); + this.handleThreadCancel(channel, origArgs); + return; + } - if (size <= 0) continue; + // --- Scatter/gather I/O (writev/readv/pwritev/preadv) --- + // These have nested pointers (iov array → base buffers) that can't be + // handled by the simple ArgDesc system. + if (syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV) { + if (logging) console.error(logEntry); + this.handleWritev(channel, syscallNr, origArgs); + return; + } - // Cap size to match what was done in handleSyscall's input phase - if (outOffset + size > CH_DATA_SIZE) { - size = CH_DATA_SIZE - outOffset; - if (size <= 0) continue; - } + if (syscallNr === SYS_READV || syscallNr === SYS_PREADV) { + if (logging) console.error(logEntry); + this.handleReadv(channel, syscallNr, origArgs); + return; + } - const kernelPtr = dataStart + outOffset; + // --- Large write/pwrite/read/pread: chunk through scratch buffer --- + // When the data exceeds CH_DATA_SIZE, the ArgDesc path returns a short + // read/write. Programs like InnoDB that write 1MB+ chunks may exhaust + // their retry budget. Handle large I/O by looping on the host side. + if ((syscallNr === SYS_WRITE || syscallNr === SYS_PWRITE) && origArgs[2] > CH_DATA_SIZE) { + this.handleLargeWrite(channel, syscallNr, origArgs); + return; + } + if ((syscallNr === SYS_READ || syscallNr === SYS_PREAD) && origArgs[2] > CH_DATA_SIZE) { + this.handleLargeRead(channel, syscallNr, origArgs); + return; + } - // Copy output data from kernel to process - if (desc.direction === "out" || desc.direction === "inout") { - // Don't copy pure "out" data back when the syscall failed — the - // kernel scratch was zeroed in the input phase and the kernel - // didn't produce any output. Copying zeros back would corrupt - // the caller's buffer (e.g. readlink overwrites a valid path - // with zeros when the target isn't a symlink). - if (desc.direction === "out" && retVal < 0) { - outOffset += size; - outOffset = (outOffset + 3) & ~3; - continue; - } - let copySize = size; - if (desc.direction === "out" && desc.size.type === "arg") { - // For read/recv-like syscalls, retVal is bytes read — limit copy to actual data - const copyRetvalAdd = desc.copyRetvalAdd ?? 0; - if (retVal > 0 && retVal + copyRetvalAdd < size) { - copySize = retVal + copyRetvalAdd; - } - } - processMem.set( - kernelMem.subarray(kernelPtr, kernelPtr + copySize), - origPtr, - ); - } + // --- sendmsg/recvmsg: decompose msghdr from process memory --- + if (syscallNr === SYS_SENDMSG) { + this.handleSendmsg(channel, origArgs); + return; + } + if (syscallNr === SYS_RECVMSG) { + this.handleRecvmsg(channel, origArgs); + return; + } - outOffset += size; - outOffset = (outOffset + 3) & ~3; + // --- ioctl: intercept network interface ioctls --- + // These require host-side handling because: + // SIOCGIFCONF: struct ifconf contains a pointer to a process-memory buffer + // SIOCGIFHWADDR: returns the virtual MAC address for this kernel instance + if (syscallNr === SYS_IOCTL) { + const request = origArgs[1] >>> 0; + if (request === SIOCGIFCONF) { + this.handleIoctlIfconf(channel, origArgs); + return; + } + if (request === SIOCGIFNAME) { + this.handleIoctlIfname(channel, origArgs); + return; + } + if (request === SIOCGIFHWADDR) { + this.handleIoctlIfhwaddr(channel, origArgs); + return; + } + if (request === SIOCGIFADDR) { + this.handleIoctlIfaddr(channel, origArgs); + return; + } + if (request === SIOCGIFINDEX) { + this.handleIoctlIfindex(channel, origArgs); + return; } } - // Clear handling flag (channel is done — poller can pick it up for next syscall) - channel.handling = false; + // --- fcntl with struct flock pointer --- + // When cmd is a lock operation, arg3 is a pointer to struct flock (32 bytes). + // Handle as inout so the kernel can read/write the flock struct. + if (syscallNr === SYS_FCNTL) { + const cmd = origArgs[1]; + if (cmd === F_GETLK || cmd === F_SETLK || cmd === F_SETLKW || + cmd === F_GETLK64 || cmd === F_SETLK64 || cmd === F_SETLKW64 || + cmd === F_OFD_GETLK || cmd === F_OFD_SETLK || cmd === F_OFD_SETLKW) { + this.handleFcntlLock(channel, origArgs); + return; + } + } - // Write result to process channel - processView.setBigInt64(CH_RETURN, BigInt(retVal), true); - processView.setUint32(CH_ERRNO, errVal, true); + // --- epoll: intercept all epoll syscalls on host side --- + // kernel_handle_channel crashes in Chrome (V8 shared-memory Wasm bug) for + // epoll_pwait. Handle epoll_create1/ctl on the kernel but mirror the + // interest list, and convert epoll_pwait to poll entirely on the host. + if (syscallNr === SYS_EPOLL_CREATE1 || syscallNr === SYS_EPOLL_CREATE) { + this.handleEpollCreate(channel, syscallNr, origArgs); + return; + } + if (syscallNr === SYS_EPOLL_CTL) { + this.handleEpollCtl(channel, origArgs); + return; + } + if (syscallNr === SYS_EPOLL_PWAIT || syscallNr === SYS_EPOLL_WAIT) { + this.handleEpollPwait(channel, syscallNr, origArgs); + return; + } - // Cancel any pending socket timeout timer for this channel - this.clearSocketTimeout(channel); + // --- SysV IPC: shmat/shmdt need host-side process memory management --- + if (syscallNr === SYS_SHMAT) { + this.handleIpcShmat(channel, origArgs); + return; + } + if (syscallNr === SYS_SHMDT) { + this.handleIpcShmdt(channel, origArgs); + return; + } + // --- SysV IPC: semctl has cmd-dependent arg types (scalar vs pointer) --- + if (syscallNr === SYS_SEMCTL) { + this.handleSemctl(channel, origArgs); + return; + } - // Drain PTY output buffers before notifying the process — slave writes + // (POSIX mqueue syscalls 331-336 now go through the normal kernel path) + + // --- pselect6: fd_sets (inout) + timeout/sigmask decoding --- + if (syscallNr === SYS_PSELECT6) { + this.handlePselect6(channel, origArgs); + return; + } + + // --- select(2): same shape as pselect6 but with `struct timeval` + // (sec, usec) and no sigmask. musl's select.c routes here on wasm64 + // because `__NR_pselect6_time64` isn't defined for that arch (unlike + // wasm32, which aliases it to __NR_pselect6). Without this intercept, + // sys_select returns EAGAIN when it needs host-managed waiting, and the + // generic blocking-retry has no select-timeout awareness — every + // `select(0,0,0,0,&tv)` (= my_sleep) becomes an infinite loop. That + // surfaced as the wasm64 mariadbd boot hang at + // wait_for_signal_thread_to_end's kill+my_sleep loop. + if (syscallNr === SYS_SELECT) { + this.handleSelect(channel, origArgs); + return; + } + + // --- Normal syscall path --- + // Linux requires room for one kernel-word mask and a kernel-word-aligned + // length. The descriptor marshals only the fixed four bytes Kandelo can + // write, so a larger valid request is not constrained by channel capacity. + if ( + syscallNr === SYS_SCHED_GETAFFINITY + && ( + origArgs[1] < SCHED_AFFINITY_MASK_SIZE + || origArgs[1] % SCHED_AFFINITY_MASK_SIZE !== 0 + ) + ) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EINVAL, + ); + return; + } + + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + + // Copy raw args to kernel scratch header (will be adjusted below) + const adjustedArgs = [...origArgs]; + + // Process pointer args: copy data between process and kernel memory + const argDescs = SYSCALL_ARGS[syscallNr]; + let dataOffset = 0; // Offset within scratch data area for allocations + let schedGetaffinityOutputInvalid = false; + + if (argDescs) { + // Re-create typed views (memory may have grown) + const processMem = new Uint8Array(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const dataStart = this.scratchOffset + CH_DATA; + + for (const desc of argDescs) { + const ptr = origArgs[desc.argIndex]; + const deferSchedGetaffinityOutputError = + syscallNr === SYS_SCHED_GETAFFINITY + && desc.argIndex === 2 + && desc.direction === "out"; + if (ptr === 0 && !deferSchedGetaffinityOutputError) { + const required = desc.required === true + || (desc.size.type === "cstring" && desc.nullable !== true); + if (required) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + continue; + } + + // Compute size of data to copy + let size: number; + if (desc.size.type === "cstring") { + const result = cstringCopySize( + processMem, + ptr, + CH_DATA_SIZE - dataOffset, + ); + if ("errno" in result) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + result.errno, + ); + return; + } + size = result.size; + } else if (desc.size.type === "arg") { + size = + origArgs[desc.size.argIndex] * (desc.size.multiplier ?? 1) + + (desc.size.add ?? 0); + } else if (desc.size.type === "deref") { + // Dereference: arg is a pointer to a u32 value (e.g. socklen_t*) + const derefPtr = origArgs[desc.size.argIndex]; + if (derefPtr === 0) continue; + if (!isValidMemoryRange(processMem, derefPtr, 4)) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + size = processMem[derefPtr] | (processMem[derefPtr + 1] << 8) + | (processMem[derefPtr + 2] << 16) | (processMem[derefPtr + 3] << 24); + } else { + size = desc.size.size; + } + + if (size <= 0) continue; + + // Cap size to fit in the channel data buffer. For read/write-like + // syscalls where the size comes from another arg, also update that + // arg so the kernel uses the capped count. The caller (musl libc) + // will see a short read/write and retry for the remainder. + if (dataOffset + size > CH_DATA_SIZE) { + size = CH_DATA_SIZE - dataOffset; + if (size <= 0) continue; + if (desc.size.type === "arg") { + adjustedArgs[desc.size.argIndex] = size; + } + } + + if (!isValidMemoryRange(processMem, ptr, size)) { + if (deferSchedGetaffinityOutputError) { + // Linux resolves the requested task before copying its affinity + // mask. Use safe kernel scratch now, then convert a successful + // lookup to EFAULT below; an ESRCH result must take precedence. + schedGetaffinityOutputInvalid = true; + } else { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EFAULT, + ); + return; + } + } + + const kernelPtr = dataStart + dataOffset; + + // Copy input data from process to kernel + if (desc.direction === "in" || desc.direction === "inout") { + kernelMem.set(processMem.subarray(ptr, ptr + size), kernelPtr); + } else { + // Output-only: zero the kernel scratch area + kernelMem.fill(0, kernelPtr, kernelPtr + size); + } + + // Update arg to point to kernel memory + adjustedArgs[desc.argIndex] = kernelPtr; + + dataOffset += size; + // Kernel exports may dereference i64-bearing structs and scalar output + // slots directly. Keep every following allocation eight-byte aligned; + // CH_DATA itself is eight-byte aligned. + dataOffset = (dataOffset + 7) & ~7; + } + } + + // ppoll: convert timespec pointer and sigset pointer to scalar values. + // musl sends: (fds, nfds, timespec_ptr, sigset_ptr, sigset_size) + // kernel expects: (fds, nfds, timeout_ms, has_mask, mask_lo, mask_hi) + if (syscallNr === SYS_PPOLL) { + const tsPtr = origArgs[2]; + if (tsPtr !== 0) { + // time64: timespec is {int64 sec, int64 nsec} = 16 bytes + const pv = new DataView(channel.memory.buffer, tsPtr); + const sec = Number(pv.getBigInt64(0, true)); + const nsec = Number(pv.getBigInt64(8, true)); + adjustedArgs[2] = sec * 1000 + Math.floor(nsec / 1000000); + } else { + adjustedArgs[2] = -1; // infinite timeout + } + const maskPtr = origArgs[3]; + if (maskPtr !== 0) { + const pv = new DataView(channel.memory.buffer, maskPtr); + adjustedArgs[3] = 1; // has_mask = true + adjustedArgs[4] = pv.getUint32(0, true); // mask_lo + adjustedArgs[5] = pv.getUint32(4, true); // mask_hi + } else { + adjustedArgs[3] = 0; // has_mask = false + adjustedArgs[4] = 0; + adjustedArgs[5] = 0; + } + } + + if ( + channel.readinessFinalCheck === true + && (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) + ) { + // The Rust poll/ppoll path sees timeout=0 and returns a real readiness + // result. For ppoll, that non-EAGAIN result also restores the saved mask. + adjustedArgs[2] = 0; + channel.readinessFinalCheck = false; + } + + let fileSharedMmapPreparation: FileSharedMmapPreparationResult | null = null; + if ( + syscallNr === SYS_MMAP + && (origArgs[1] >>> 0) > 0 + && (origArgs[3] & MAP_SHARED) !== 0 + && (origArgs[3] & MAP_ANONYMOUS) === 0 + && origArgs[4] >= 0 + ) { + const preparation = this.prepareSharedMmapFromFile(channel, origArgs); + if (this.hostReaped?.has(channel.pid)) return; + if (preparation.kind === "error") { + // Regular-file host setup is part of mmap. Fail before invoking the + // kernel so MAP_FIXED cannot destroy an existing interval first. + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + preparation.errno, + ); + return; + } + fileSharedMmapPreparation = preparation; + } + + try { + if (syscallNr === SYS_MREMAP) { + const preflightError = this.preflightFileSharedMremap( + channel.pid, + origArgs, + ); + if (preflightError !== 0) { + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + preflightError, + ); + return; + } + } + + try { + if (syscallNr === SYS_MMAP && (origArgs[3] & MAP_FIXED) !== 0) { + if (!this.ensureFixedMmapProcessMemoryCapacity(channel, origArgs)) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + ENOMEM, + ); + return; + } + // Flush the replaced mapping while its kernel interval and process + // bytes are both still intact. + const flushedReplacement = this.flushSharedMappings(channel, [ + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]); + if (this.hostReaped?.has(channel.pid)) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + return; + } + if (!flushedReplacement) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + this.completeChannel( + channel, + syscallNr, + origArgs, + undefined, + -1, + EIO, + ); + return; + } + } + + // Write adjusted args to kernel scratch + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64( + CH_ARGS + i * CH_ARG_SIZE, + BigInt(adjustedArgs[i]), + true, + ); + } + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } + + // Call kernel_handle_channel + const handleChannel = this.kernelInstance!.exports + .kernel_handle_channel as ( + offset: KernelPointer, + pid: number, + ) => number; + this.currentHandlePid = channel.pid; + try { + this.bindKernelTidForChannel(channel); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } + // DIAGNOSTIC: globalThis.__sysprof aggregates per-(pid,syscall_nr) + // timing across kernel_handle_channel calls so we can dump a profile + // afterward (via globalThis.__sysprofDump()). Off by default — flip on + // from the demo page right before the slow operation, off after. + // Also tracks wall-clock gap since *this* pid's previous syscall — that + // gap is the time the pid spent in user wasm code, the actual perf + // bottleneck when kernel-side handling itself is fast. + const sysprof = (globalThis as { __sysprof?: boolean }).__sysprof; + const sysprofStart = sysprof ? performance.now() : 0; + if (sysprof) { + type GapRow = { count: number; gapTotalMs: number; gapMaxMs: number }; + const g = globalThis as { + __sysprofGap?: Map; + __sysprofLastSeen?: Map; + }; + if (!g.__sysprofGap) g.__sysprofGap = new Map(); + if (!g.__sysprofLastSeen) g.__sysprofLastSeen = new Map(); + const last = g.__sysprofLastSeen.get(channel.pid); + if (last !== undefined) { + const gap = sysprofStart - last; + let row = g.__sysprofGap.get(channel.pid); + if (!row) { + row = { count: 0, gapTotalMs: 0, gapMaxMs: 0 }; + g.__sysprofGap.set(channel.pid, row); + } + row.count++; + row.gapTotalMs += gap; + if (gap > row.gapMaxMs) row.gapMaxMs = gap; + } + g.__sysprofLastSeen.set(channel.pid, sysprofStart); + } + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + // If the kernel throws (e.g., invalid memory access), complete the + // channel with -EIO to unblock the process rather than deadlocking. + if (logging) console.error(logEntry + " = KERNEL THROW"); + console.error( + `[handleSyscall] kernel threw for pid=${channel.pid} syscall=${syscallNr} args=[${origArgs}]:`, + err, + ); + if (syscallNr === SYS_RT_SIGTIMEDWAIT) { + this.signalWaitDeadlines.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + } + this.completeChannelRaw(channel, -5, 5); // -EIO + this.relistenChannel(channel); + return; + } finally { + this.currentHandlePid = 0; + if (sysprof) { + const elapsed = performance.now() - sysprofStart; + type ProfRow = { count: number; totalMs: number; maxMs: number }; + const g = globalThis as { __sysprofTable?: Map }; + if (!g.__sysprofTable) g.__sysprofTable = new Map(); + const key = `${channel.pid}:${syscallNr}`; + let row = g.__sysprofTable.get(key); + if (!row) { + row = { count: 0, totalMs: 0, maxMs: 0 }; + g.__sysprofTable.set(key, row); + } + row.count++; + row.totalMs += elapsed; + if (elapsed > row.maxMs) row.maxMs = elapsed; + if (elapsed > 50) { + console.warn( + `[sysprof] slow pid=${channel.pid} nr=${syscallNr} ${elapsed.toFixed(1)}ms args=[${origArgs.join(",")}]`, + ); + } + } + } + + // Stop signal death before any host postprocessing can re-enter the kernel + // or mutate state for an execution that must never resume. + if (this.getProcessExitSignal(channel.pid) > 0) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + this.handleProcessTerminated(channel); + return; + } + + // Read return value and errno from kernel scratch + let retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + let errVal = kernelView.getUint32(CH_ERRNO, true); + if ( + syscallNr === SYS_RT_SIGTIMEDWAIT && + !(retVal === -1 && errVal === EAGAIN) + ) { + this.signalWaitDeadlines.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + } + if ( + syscallNr === SYS_SCHED_GETAFFINITY + && schedGetaffinityOutputInvalid + && retVal >= 0 + ) { + retVal = -1; + errVal = EFAULT; + } + if ( + syscallNr === SYS_MMAP && + fileSharedMmapPreparation?.kind === "prepared" && + !(retVal > 0 && retVal >>> 0 !== 0xffffffff) + ) { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + + // MAP_FIXED's old interval was published and flushed before the kernel + // call. After success, detach its trackers before registering the new map. + if ( + syscallNr === SYS_MMAP && + retVal > 0 && + (origArgs[3] & MAP_FIXED) !== 0 + ) { + const replacementArgs = [ + retVal >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]; + this.cleanupSharedMappings( + channel.pid, + replacementArgs[0]!, + replacementArgs[1]!, + ); + } + if (syscallNr === SYS_MREMAP && retVal > 0) { + this.flushSharedMappings(channel, [ + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]); + if (this.hostReaped?.has(channel.pid)) return; + } + + // --- Process memory growth for brk/mmap/mremap --- + // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory, not + // the process's. We must grow the process's + // WebAssembly.Memory here so the process can access the new addresses. + if (retVal > 0) { + try { + this.ensureProcessMemoryCovers( + channel.pid, + channel.memory, + syscallNr, + retVal, + origArgs, + ); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } + } + + // --- DEBUG: detect memory operations in legacy high control pages --- + const highControlFloor = this.highControlFloorForProcess(channel.pid); + if (syscallNr === SYS_MMAP && retVal > 0 && retVal >>> 0 !== 0xffffffff) { + const mmapAddr = retVal >>> 0; + const mmapLen = origArgs[1] >>> 0; + if ( + highControlFloor !== null && + mmapAddr + mmapLen > highControlFloor + ) { + console.error( + `[MMAP ALERT] pid=${channel.pid} mmap returned 0x${mmapAddr.toString(16)} len=${mmapLen} — OVERLAPS THREAD REGION! args=[${origArgs.map((a) => "0x" + (a >>> 0).toString(16)).join(",")}]`, + ); + } + } + if ( + syscallNr === SYS_MREMAP && + retVal > 0 && + retVal >>> 0 !== 0xffffffff + ) { + const mremapAddr = retVal >>> 0; + const mremapLen = origArgs[2] >>> 0; + if ( + highControlFloor !== null && + mremapAddr + mremapLen > highControlFloor + ) { + console.error( + `[MREMAP ALERT] pid=${channel.pid} mremap returned 0x${mremapAddr.toString(16)} len=${mremapLen} — OVERLAPS THREAD REGION!`, + ); + } + } + if ( + highControlFloor !== null && + syscallNr === SYS_BRK && + retVal > highControlFloor + ) { + console.error( + `[BRK ALERT] pid=${channel.pid} brk returned 0x${(retVal >>> 0).toString(16)} — IN THREAD REGION!`, + ); + } + + // --- mmap backing: populate files and register shared-memory intervals --- + if (syscallNr === SYS_MMAP && retVal > 0 && retVal >>> 0 !== 0xffffffff) { + const mmapFd = origArgs[4]; + const mmapFlags = origArgs[3] >>> 0; + if ( + (mmapFlags & MAP_SHARED) !== 0 && + (mmapFlags & MAP_ANONYMOUS) !== 0 + ) { + this.trackAnonymousSharedMapping(channel, retVal >>> 0, origArgs); + } else if (mmapFd >= 0 && (mmapFlags & MAP_ANONYMOUS) === 0) { + if ((mmapFlags & MAP_SHARED) !== 0) { + const sharedResult = + fileSharedMmapPreparation?.kind === "prepared" + ? this.registerPreparedSharedMmap( + channel, + retVal >>> 0, + fileSharedMmapPreparation.context, + ) + : fileSharedMmapPreparation?.kind === "unsupported" + ? fileSharedMmapPreparation + : this.mapSharedMmapFromFile(channel, retVal >>> 0, origArgs); + fileSharedMmapPreparation = null; + if (this.hostReaped?.has(channel.pid)) return; + if (sharedResult.kind === "unsupported") { + this.populateMmapFromFile(channel, retVal >>> 0, origArgs); + if (this.hostReaped?.has(channel.pid)) return; + } else if (sharedResult.kind === "error") { + // The kernel has already reserved the interval. Undo that + // allocation and report the host-backing failure truthfully; + // silently leaving an untracked MAP_SHARED mapping would lose + // writes and violate fd-close/fork coherence. + try { + this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [ + retVal >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]); + if (this.hostReaped?.has(channel.pid)) return; + } catch { + // Preserve the original mmap failure even if rollback itself + // cannot be completed. The guest must not observe success. + } + retVal = -1; + errVal = sharedResult.errno; + } + } else { + this.populateMmapFromFile(channel, retVal >>> 0, origArgs); + if (this.hostReaped?.has(channel.pid)) return; + } + } + // DRI bo mmap prime: the kernel's sys_mmap on /dev/dri/{render,card} + // already called `host_gbm_bo_bind` to record metadata, but the + // actual SAB→Memory copy is deferred until here so the + // anonymous-mmap zero-fill is in place first. This is what + // delivers the parent's writes to a child across PRIME + // export → fork → PRIME import. No-op for non-DRI mmaps. + if (retVal > 0) { + const mmapAddr = retVal >>> 0; + const boId = this.kernel.bos.findBindingByAddr(channel.pid, mmapAddr); + if (boId !== undefined) { + this.kernel.bos.primeBindFromSab(channel.pid, boId, channel.memory); + } + } + } + + // --- msync: flush MAP_SHARED regions back to file --- + if (syscallNr === SYS_MSYNC && retVal === 0) { + if (!this.flushSharedMappings(channel, origArgs)) { + retVal = -1; + errVal = EIO; + } + if (this.hostReaped?.has(channel.pid)) return; + } + + // --- munmap: flush + clean up shared mapping tracking --- + if (syscallNr === SYS_MUNMAP && retVal === 0) { + const unmapArgs = [ + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + ]; + this.flushSharedMappings(channel, unmapArgs); + if (this.hostReaped?.has(channel.pid)) return; + this.cleanupSharedMappings(channel.pid, unmapArgs[0]!, unmapArgs[1]!); + } + + if (syscallNr === SYS_MREMAP && retVal > 0) { + this.remapSharedMapping( + channel.pid, + origArgs[0] >>> 0, + retVal >>> 0, + origArgs[2] >>> 0, + ); + } + if (syscallNr === SYS_MPROTECT && retVal === 0) { + this.updateSharedMappingProtection( + channel.pid, + origArgs[0] >>> 0, + alignWasmPageLength(origArgs[1] >>> 0), + (origArgs[2] & PROT_WRITE) !== 0, + ); + } + + if ((this.sharedMmapBackings?.size ?? 0) > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, + syscallNr, + origArgs, + retVal, + errVal, + ); + if (this.hostReaped?.has(channel.pid)) return; + } + + // --- POSIX mqueue notification --- + // After mq_timedsend, the kernel may have a pending notification (signal + // to deliver when a message arrives on a previously empty queue). + const routedMqNotification = + syscallNr === SYS_MQ_TIMEDSEND && retVal === 0; + if (routedMqNotification) { + this.drainMqueueNotification(); + if (this.finishSignalTermination(channel)) return; + } + + // --- Signal delivery --- + // After each syscall, check if the kernel has a pending Handler signal. + // If so, dequeue it and write delivery info to the process channel. + // The glue code (channel_syscall.c) will invoke the handler after waking. + // A successful mq_timedsend may synchronously route a notification to a + // different process, which resets the kernel's ambient TID to the shared + // signal context. Rebind only on that uncommon path; ordinary syscall + // completion stays free of another host-to-kernel call. + this.dequeueSignalForDelivery(channel, routedMqNotification); + if (routedMqNotification && this.finishSignalTermination(channel)) return; + + // --- Blocking syscall handling --- + // Host-delegated AF_INET connect has its own public pending errnos. Keep + // EINPROGRESS/EALREADY visible to non-blocking callers, while blocking + // callers remain parked in the same host-owned retry loop as EAGAIN. + // The sockaddr-family guard deliberately excludes AF_UNIX from this + // transport-specific retry rule. + if (this.handlePendingInetConnect(channel, syscallNr, origArgs, retVal, errVal)) { + return; + } + + // 1. EAGAIN: kernel returned EAGAIN for a blocking syscall. + // Schedule async retry — the process stays blocked on Atomics.wait. + if (retVal === -1 && errVal === EAGAIN) { + if (logging) { + console.error(logEntry + " = -1 (EAGAIN, will retry)"); + } + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; + } + + // 2. Sleep syscalls: kernel returned success immediately, but we need + // to delay the response to simulate the sleep duration. + if (this.handleSleepDelay(channel, syscallNr, origArgs, retVal, errVal)) { + return; + } + + // --- Process group change: re-check deferred waitpid calls --- + // When a process changes its pgid (setpgid/setsid), a parent blocked in + // waitpid(-pgid) may no longer have any matching children. Wake it with ECHILD. + if ( + errVal === 0 && + (syscallNr === SYS_SETPGID || syscallNr === SYS_SETSID) + ) { + this.recheckDeferredWaitpids(); + } + + // --- Signal generation: wake blocked peers + reap terminating actions --- + // kill(), tkill()/pthread_kill(), and rt_sigqueueinfo() can all target a + // thread parked in a host-owned blocking operation. They can also apply + // process-wide stop/continue/terminate actions synchronously. + // + // Two follow-ups are required: + // (a) Wake any blocked syscalls on the target (pipe/poll/select) so + // their handlers observe the new exit state and complete with the + // right errno (handled by scheduleWakeBlockedRetries). + // (b) For any process the kernel marked Exited but that is still + // blocked in a non-blocking-retry path (most importantly + // pendingSleeps), call handleProcessTerminated directly so the + // parent's wait4 actually sees the killed child. Without this, + // a `kill` of a sleeping child can leave the parent blocked even + // though Rust has marked the child as an Exited zombie. + if ( + errVal === 0 && + (syscallNr === SYS_KILL || + syscallNr === SYS_TKILL || + syscallNr === SYS_RT_SIGQUEUEINFO) + ) { + // Apply STOPPED/CONTINUED transitions before waking a target's deferred + // syscall. `kill` has no descriptor output, so this scratch reuse is safe. + this.drainAndProcessWakeupEvents(); + this.scheduleWakeBlockedRetries(); + this.reapKilledProcessesAfterSyscall(); + if (syscallNr === SYS_TKILL) { + this.wakePendingSignalWaits( + channel.pid, + origArgs[1] >>> 0, + origArgs[0] >>> 0, + ); + const interruptedDirectedWait = + this.interruptWaitingChildForDirectedSignal( + channel.pid, + origArgs[0], + ); + if (!interruptedDirectedWait) { + // Unknown/stale TIDs intentionally fall back to shared delivery in + // kernel_tkill; preserve that compatibility path. + this.interruptWaitingChildrenForGeneratedSignal(origArgs[1]); + } + } else { + this.interruptWaitingChildrenForGeneratedSignal(origArgs[1]); + } + } + + // --- Normal completion --- + if (logging) { + console.error( + logEntry + this.formatSyscallReturn(syscallNr, retVal, errVal), + ); + } + this.completeChannel( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + errVal, + ); + } catch (err) { + if (fileSharedMmapPreparation?.kind === "prepared") { + this.releasePreparedSharedMmap(fileSharedMmapPreparation.context); + fileSharedMmapPreparation = null; + } + throw err; + } + } + + /** + * Dequeue one pending Handler signal from the kernel and write delivery + * info to the process channel. The glue code (channel_syscall.c) reads + * this after the syscall returns and invokes the handler. Returns the + * handler signal number, or zero when no caught handler was dequeued. + */ + private dequeueSignalForDelivery( + channel: ChannelInfo, + bindTidForAsyncCompletion = false, + ): number { + const preparedSignals = this.resumePreparedSignals; + if (preparedSignals?.has(channel)) { + const existingSignal = new DataView( + channel.memory.buffer, + channel.channelOffset, + ).getUint32(CH_SIG_SIGNUM, true); + if (existingSignal > 0) return existingSignal; + // The channel was retired or the guest consumed the record without a + // normal publication path. Do not suppress a genuinely new signal. + preparedSignals.delete(channel); + } + + const dequeueSignal = this.kernelInstance!.exports.kernel_dequeue_signal as + ((pid: number, outPtr: KernelPointer) => number) | undefined; + if (!dequeueSignal) return 0; + + // Normal syscall paths bind the channel before entering the kernel. Async + // completions can run after another thread changed the ambient TID, so + // those callers request an exact-channel rebind here. + if (bindTidForAsyncCompletion) this.bindKernelTidForChannel(channel); + + // Use the signal area in kernel scratch as the output buffer + const sigOutOffset = this.scratchOffset + CH_SIG_BASE; + const sigResult = dequeueSignal(channel.pid, this.toKernelPtr(sigOutOffset)); + if (sigResult > 0) { + // Copy 44 bytes of signal delivery info from kernel scratch to process channel + // Layout: signum(4) + handler(4) + flags(4) + si_value(4) + old_mask(8) + // + si_code(4) + si_pid(4) + si_uid(4) + alt_sp(4) + alt_size(4) = 44 bytes + const kernelMem = this.getKernelMem(); + const processMem = new Uint8Array(channel.memory.buffer); + processMem.set( + kernelMem.subarray(sigOutOffset, sigOutOffset + 44), + channel.channelOffset + CH_SIG_BASE, + ); + return sigResult; + } else { + // Clear entire signal delivery area in process channel (48 bytes) + const sigStart = channel.channelOffset + CH_SIG_BASE; + new Uint8Array(channel.memory.buffer, sigStart, 48).fill(0); + return 0; + } + } + + /** + * Complete a syscall by copying output data and notifying the process. + */ + private completeChannel( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + argDescs: SyscallArgDesc[] | undefined, + retVal: number, + errVal: number, + ): void { + // Snapshot all scratch-backed output before processing kernel wake events: + // parent notification and waiter completion can re-enter the kernel and + // reuse the one shared scratch buffer. + const prepared: PreparedChannelCompletion = { + kind: "marshalled", + outputWrites: this.snapshotChannelOutput( + channel, + syscallNr, + origArgs, + argDescs, + retVal, + ), + retVal, + errVal, + materialized: false, + relistenRequested: true, + }; + + // Output and shared backing belong to the completed syscall before any + // lifecycle observer is released. A STOPPED wake can synchronously finish + // the parent's wait and let that Worker import the same backing; delaying + // materialization until after wake processing would expose stale bytes. + // The stopped child's mailbox status/notification remains parked below. + this.materializePreparedChannelCompletion(channel, prepared); + + // The syscall is logically complete even if publication must wait for a + // future SIGCONT. Retire one-shot timeout/deadline state now so no second + // completion can race the parked one. + this.clearSocketTimeout(channel); + this.clearReadinessWait(channel); + + // Drain PTY output buffers before notifying the process — slave writes // produce data in the PTY output_buf that needs to reach the host (xterm.js). this.drainAllPtyOutputs(); - // Flush TCP send pipes before notifying the process — gets PHP's - // response data to the browser without waiting for the next pump cycle - this.flushTcpSendPipes(channel.pid); + // Flush TCP send pipes before notifying the process — gets PHP's + // response data to the browser without waiting for the next pump cycle + this.flushTcpSendPipes(channel.pid); + + // This consumes process STOPPED/CONTINUED transitions before deciding + // whether CH_STATUS may be published. + this.drainAndProcessWakeupEvents(); + this.publishOrParkChannelCompletion(channel, prepared); + } + + private snapshotChannelOutput( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + argDescs: SyscallArgDesc[] | undefined, + retVal: number, + ): Array<{ ptr: number; bytes: Uint8Array }> { + if (!argDescs) return []; + + const writes: Array<{ ptr: number; bytes: Uint8Array }> = []; + const processMem = new Uint8Array(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const dataStart = this.scratchOffset + CH_DATA; + let outOffset = 0; + + for (const desc of argDescs) { + const origPtr = origArgs[desc.argIndex]; + if (origPtr === 0) continue; + + let size: number; + if (desc.size.type === "cstring") { + let len = 0; + while ( + len < CH_DATA_SIZE - outOffset - 1 && + processMem[origPtr + len] !== 0 + ) { + len++; + } + size = len + 1; + } else if (desc.size.type === "arg") { + size = + origArgs[desc.size.argIndex] * (desc.size.multiplier ?? 1) + + (desc.size.add ?? 0); + } else if (desc.size.type === "deref") { + const derefPtr = origArgs[desc.size.argIndex]; + if (derefPtr === 0) continue; + size = + processMem[derefPtr] | + (processMem[derefPtr + 1] << 8) | + (processMem[derefPtr + 2] << 16) | + (processMem[derefPtr + 3] << 24); + } else { + size = desc.size.size; + } + + if (size <= 0) continue; + if (outOffset + size > CH_DATA_SIZE) { + size = CH_DATA_SIZE - outOffset; + if (size <= 0) continue; + } + + const kernelPtr = dataStart + outOffset; + if (desc.direction === "out" || desc.direction === "inout") { + // Pure output is unspecified on failure; preserve the caller's bytes. + if (!(desc.direction === "out" && retVal < 0)) { + let copySize = size; + if (desc.direction === "out" && desc.size.type === "arg") { + const copyRetvalAdd = desc.copyRetvalAdd ?? 0; + if (retVal > 0 && retVal + copyRetvalAdd < size) { + copySize = retVal + copyRetvalAdd; + } + } + let bytes = new Uint8Array(copySize); + bytes.set(kernelMem.subarray(kernelPtr, kernelPtr + copySize)); + if ( + syscallNr === SYS_RT_SIGTIMEDWAIT && + desc.argIndex === 1 && + this.getPtrWidth(channel.pid) === 8 && + copySize >= 32 + ) { + // The kernel channel carries siginfo's meaningful fields in the + // fixed wasm32 layout: header at 0, the first union words at + // 12/16, and sival_int at 20. Musl's wasm64 siginfo_t aligns the + // union to eight bytes, moving those fields to 16/20/24. Expand + // the fixed channel record at the host boundary, where the guest + // pointer width is known. + bytes.copyWithin(16, 12, 24); + bytes.fill(0, 12, 16); + } + writes.push({ ptr: origPtr, bytes }); + } + } + + outOffset += size; + outOffset = (outOffset + 7) & ~7; + } + + return writes; + } + + private publishOrParkChannelCompletion( + channel: ChannelInfo, + prepared: PreparedChannelCompletion, + ): void { + if ( + this.stoppedPids?.has(channel.pid) && + this.isRegisteredChannel(channel) + ) { + const parkedCompletions = (this.parkedChannelCompletions ??= new Map()); + const existing = parkedCompletions.get(channel); + if (existing) { + existing.relistenRequested ||= prepared.relistenRequested; + return; + } + // A stopped process must remain parked at CH_PENDING, but a syscall that + // has completed already owns its output. Materialize it now so another + // process mapping the same backing cannot observe stale bytes until + // SIGCONT. Only the mailbox return/notification is deferred. + this.materializePreparedChannelCompletion(channel, prepared); + channel.handling = true; + this.deferredStoppedChannels?.delete(channel); + parkedCompletions.set(channel, { + prepared, + relistenRequested: prepared.relistenRequested, + }); + return; + } + + this.publishPreparedChannelCompletion(channel, prepared); + } + + private publishPreparedChannelCompletion( + channel: ChannelInfo, + prepared: PreparedChannelCompletion, + ): void { + this.materializePreparedChannelCompletion(channel, prepared); + + channel.handling = false; + const processView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + processView.setBigInt64(CH_RETURN, BigInt(prepared.retVal), true); + processView.setUint32(CH_ERRNO, prepared.errVal, true); + + // The copied signal record now belongs to the guest. A later syscall may + // dequeue another signal after this boundary has actually been observed. + this.resumePreparedSignals?.delete(channel); + // pthread_t->cancel in guest memory is authoritative. This host marker is + // only a one-shot pre-enqueue race guard and must not retain a channel + // after any actual completion (including one parked before cancel arrived). + this.pendingCancels?.delete(channel); + const i32View = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + Atomics.store(i32View, CH_STATUS / 4, CH_COMPLETE); + Atomics.notify(i32View, CH_STATUS / 4, 1); + if (prepared.relistenRequested && this.isRegisteredChannel(channel)) { + this.relistenChannel(channel); + } + } + + private materializePreparedChannelCompletion( + channel: ChannelInfo, + prepared: PreparedChannelCompletion, + ): void { + if (prepared.materialized) return; + + const processMem = new Uint8Array(channel.memory.buffer); + for (const write of prepared.outputWrites) { + processMem.set(write.bytes, write.ptr); + } + prepared.outputWrites = []; + + try { + this.synchronizeSharedMemoryForBoundary(channel); + } catch (err) { + console.error( + `[completeChannel] shared-memory synchronization failed for pid=${channel.pid}:`, + err, + ); + prepared.retVal = -EIO; + prepared.errVal = EIO; + } + prepared.materialized = true; + } + + /** Hold one exact mailbox at a syscall boundary while its process is stopped. */ + private deferChannelWhileStopped(channel: ChannelInfo): boolean { + if (!this.stoppedPids?.has(channel.pid)) return false; + if (!this.isRegisteredChannel(channel)) return true; + if (!this.parkedChannelCompletions?.has(channel)) { + (this.deferredStoppedChannels ??= new Map()).set(channel, true); + } + channel.handling = true; + return true; + } + + /** + * Publish completed mailboxes and re-arm deferred dispatches after SIGCONT. + * + * Resume is a barrier: first inspect every exact registered thread channel + * for signals retained while the process was stopped. No Worker constructor + * and no mailbox notification may run until that complete scan still leaves + * the authoritative Process Running. This prevents an earlier pthread from + * executing while a later thread's directed fatal/stop signal is still + * waiting to be applied. + * + * Returns true only when the continued transition remained current through + * release. The wake-event caller uses this to suppress a stale CONTINUED + * parent notification after a resume-time stop or exit. + */ + private resumeStoppedProcess(pid: number): boolean { + // Wake events carry a pid, not a host generation token. A delayed event + // must not release a replacement process that has since stopped again or + // recycled the same numeric pid. + const getState = this.kernelInstance!.exports.kernel_get_process_state as ( + pid: number, + ) => number; + const state = getState(pid); + if (state !== PROCESS_STATE_RUNNING) { + if (state !== PROCESS_STATE_STOPPED) { + this.discardStoppedChannelStateForProcess(pid); + } + return false; + } + + const registration = this.processes.get(pid); + if (!registration || registration.channels.length === 0) { + // Fork/spawn/exec can yield between kernel Process creation and host + // memory registration, and exec handoff deliberately retains an empty + // registration. Preserve the real CONTINUED parent event now, but keep + // execution gated until startProcessWorkerWhenRunnable can scan the + // subsequently registered exact channels. + (this.pendingResumePids ??= new Set()).add(pid); + (this.stoppedPids ??= new Set()).add(pid); + return true; + } + this.pendingResumePids?.delete(pid); + + const parkedCompletions = (this.parkedChannelCompletions ??= new Map()); + const deferredChannels = (this.deferredStoppedChannels ??= new Map()); + const preparedSignals = (this.resumePreparedSignals ??= new WeakSet()); + const caughtSignalChannels: ChannelInfo[] = []; + + // Keep the host stop gate armed throughout preflight. Any completion + // prepared while servicing a retained signal must join the parked batch, + // not wake guest code in the middle of this scan. + (this.stoppedPids ??= new Set()).add(pid); + + for (const channel of Array.from(registration.channels)) { + if (!this.isRegisteredChannel(channel)) continue; + + const channelView = new DataView( + channel.memory.buffer, + channel.channelOffset, + ); + let deliveredSignal = channelView.getUint32(CH_SIG_SIGNUM, true); + if (deliveredSignal > 0) { + // A caught signal may already have been attached before the syscall + // completion observed STOPPED, or by an earlier resume attempt whose + // later channel immediately stopped the process again. + preparedSignals.add(channel); + } else { + preparedSignals.delete(channel); + deliveredSignal = this.dequeueSignalForDelivery(channel, true); + if (deliveredSignal > 0) preparedSignals.add(channel); + } + + if (this.finishSignalTermination(channel)) return false; + const postSignalState = getState(pid); + if (postSignalState === PROCESS_STATE_STOPPED) { + this.stoppedPids.add(pid); + return false; + } + if (postSignalState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; + } + if (deliveredSignal > 0) caughtSignalChannels.push(channel); + } + + // wait4/waitid, sleeps, futexes, and readiness retries live outside an + // ordinary kernel dispatch. A caught directed signal preloaded above must + // wake those exact blockers; otherwise they can remain asleep forever + // after SIGCONT. The stop gate is still set, so every synchronous + // completion prepared here is added to parkedCompletions. + for (const channel of caughtSignalChannels) { + if (parkedCompletions.has(channel)) continue; + this.interruptStoppedChannelWithPreparedSignal(channel); + if (this.finishSignalTermination(channel)) return false; + const postInterruptState = getState(pid); + if (postInterruptState === PROCESS_STATE_STOPPED) return false; + if (postInterruptState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; + } + } + + if (getState(pid) !== PROCESS_STATE_RUNNING) return false; + this.stoppedPids.delete(pid); + + // Worker construction is the first guest-execution boundary. Release it + // only after every retained exact-thread signal has been preflighted, and + // only for the exact memory generation prepared while this Process was + // stopped. Clone-specific start failure may still replace its parked + // success result before any completion is published. + const starts = this.deferredProcessWorkerStarts.get(pid); + if (starts) { + this.deferredProcessWorkerStarts.delete(pid); + const pendingStarts = Array.from(starts); + for (let i = 0; i < pendingStarts.length; i++) { + const entry = pendingStarts[i]; + const currentRegistration = this.processes.get(pid); + if ( + !currentRegistration || + currentRegistration.memory !== entry.expectedMemory + ) { + entry.cancel(); + continue; + } + try { + entry.start(); + } catch (error) { + entry.cancel(); + console.error( + `[kernel-worker] deferred Worker launch failed for pid=${pid}:`, + error, + ); + if (entry.onStartError?.(error) === true) { + continue; + } + for (const remaining of pendingStarts.slice(i + 1)) { + try { + remaining.cancel(); + } catch { + /* best-effort */ + } + } + this.notifyHostProcessCrashed(pid); + // No backing process Worker exists to emit a later error/exit event. + // Drive the normal entry-layer teardown now so any Workers that did + // start for this generation are terminated and registries are retired. + if (this.callbacks.onExit) this.callbacks.onExit(pid, 128 + 11); + return false; + } + } + } + + const parked = Array.from(parkedCompletions.entries()).filter( + ([channel]) => channel.pid === pid, + ); + for (const [channel, entry] of parked) { + if (parkedCompletions.get(channel) !== entry) continue; + if (!this.isRegisteredChannel(channel)) { + parkedCompletions.delete(channel); + deferredChannels.delete(channel); + continue; + } + const releaseState = getState(pid); + if (releaseState === PROCESS_STATE_STOPPED) { + this.stoppedPids.add(pid); + return false; + } + if (releaseState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; + } + parkedCompletions.delete(channel); + deferredChannels.delete(channel); + entry.prepared.relistenRequested ||= entry.relistenRequested; + this.publishPreparedChannelCompletion(channel, entry.prepared); + } + + const deferred = Array.from(deferredChannels.keys()).filter( + (channel) => channel.pid === pid, + ); + for (const channel of deferred) { + deferredChannels.delete(channel); + if (!this.isRegisteredChannel(channel)) continue; + channel.handling = false; + // Re-enter through the normal listener so polling mode and the browser's + // event-loop yielding policy retain their existing behavior. + this.relistenChannel(channel); + } + + const finalState = getState(pid); + if (finalState === PROCESS_STATE_STOPPED) { + this.stoppedPids.add(pid); + return false; + } + if (finalState !== PROCESS_STATE_RUNNING) { + this.discardStoppedChannelStateForProcess(pid); + return false; + } + return true; + } + + /** + * Wake one host-owned blocker after resume preflight already copied a caught + * signal into its exact channel. The process stop gate remains armed, so a + * synchronous completion is parked until the full process scan succeeds. + */ + private interruptStoppedChannelWithPreparedSignal( + channel: ChannelInfo, + ): boolean { + const waitIndex = this.waitingForChild.findIndex( + (waiter) => waiter.channel === channel, + ); + if (waitIndex >= 0) { + const [waiter] = this.waitingForChild.splice(waitIndex, 1); + if (this.interruptWaiterWithPendingSignal(waiter)) return true; + this.waitingForChild.splice(waitIndex, 0, waiter); + return false; + } + + const sleep = this.pendingSleeps.get(channel); + if (sleep) { + clearTimeout(sleep.timer); + this.pendingSleeps.delete(channel); + this.completeSleepWithSignalCheck( + sleep.channel, + sleep.syscallNr, + sleep.origArgs, + sleep.retVal, + sleep.errVal, + ); + return true; + } + + const futex = this.pendingFutexWaits.get(channel); + if (futex) { + if (futex.interrupt) { + futex.interrupt(-EINTR_ERRNO, EINTR_ERRNO); + } else { + Atomics.notify( + new Int32Array(channel.memory.buffer), + futex.futexIndex, + 1, + ); + } + return true; + } + + let blocked = + this.pendingPollRetries.has(channel) || + this.pendingSelectRetries.has(channel); + for (const readers of this.pendingPipeReaders.values()) { + if (readers.some((reader) => reader.channel === channel)) { + blocked = true; + break; + } + } + if (!blocked) { + for (const writers of this.pendingPipeWriters.values()) { + if (writers.some((writer) => writer.channel === channel)) { + blocked = true; + break; + } + } + } + if (!blocked) return false; + + this.removePendingPipeReader(channel); + this.removePendingPipeWriter(channel); + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(channel); + return true; + } + + /** + * Replace the parked success result of a clone whose deferred thread Worker + * could not be constructed. The entry layer separately rolls back ThreadInfo, + * channel, allocator, and Worker registries before this completion publishes. + */ + failDeferredCloneLaunch(pid: number, tid: number, errno: number): boolean { + for (const [channel, parked] of this.parkedChannelCompletions ?? []) { + if (channel.pid !== pid || parked.prepared.retVal !== tid) continue; + const view = new DataView(channel.memory.buffer, channel.channelOffset); + if (view.getUint32(CH_SYSCALL, true) !== SYS_CLONE) continue; + + const flags = Number(view.getBigInt64(CH_ARGS, true)); + const ptidPtr = Number(view.getBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, true)); + const CLONE_PARENT_SETTID = 0x00100000; + if ( + (flags & CLONE_PARENT_SETTID) !== 0 && + isValidMemoryRange(new Uint8Array(channel.memory.buffer), ptidPtr, 4) + ) { + new DataView(channel.memory.buffer).setInt32(ptidPtr, 0, true); + } + + parked.prepared.outputWrites = []; + parked.prepared.retVal = -1; + parked.prepared.errVal = errno; + return true; + } + return false; + } + + /** Discard state that must never publish into a dead or replaced channel. */ + private discardStoppedChannelStateForProcess( + pid: number, + clearProcessStop = true, + ): void { + const starts = this.deferredProcessWorkerStarts?.get(pid); + if (starts) { + this.deferredProcessWorkerStarts.delete(pid); + for (const entry of starts) { + try { + entry.cancel(); + } catch { + /* best-effort generation teardown */ + } + } + } + for (const channel of Array.from( + this.parkedChannelCompletions?.keys() ?? [], + )) { + if (channel.pid === pid) this.parkedChannelCompletions.delete(channel); + } + for (const channel of Array.from( + this.deferredStoppedChannels?.keys() ?? [], + )) { + if (channel.pid === pid) this.deferredStoppedChannels.delete(channel); + } + if (clearProcessStop) this.stoppedPids?.delete(pid); + if (clearProcessStop) this.pendingResumePids?.delete(pid); + } + + private discardStoppedChannelState(channel: ChannelInfo): void { + this.parkedChannelCompletions?.delete(channel); + this.deferredStoppedChannels?.delete(channel); + } + + /** + * Host-teardown reclamation. + * + * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — WORKAROUND, remove when the engine bug + * is fixed; see docs/jsc-terminate-atomics-wait-workaround.md. + * + * On JSC (Safari, and Bun via `bun`'s JavaScriptCore), `Worker.terminate()` + * cannot kill (or free the memory of) a worker parked in `Atomics.wait` on + * its syscall channel — which is where every idle/blocked process worker sits + * (accept, read, poll, select, sleep, futex, the channel round-trip). + * Terminating them directly leaks their threads + committed working set, so + * each image switch accumulates a whole machine and the tab OOMs. V8 (Chrome, + * Node) interrupts the wait on terminate and reclaims, so this is a no-op cost + * there and is invoked unconditionally by both host entries for parity. + * + * For every worker currently parked at CH_PENDING we complete its syscall + * with EINTR AND queue a SIGKILL into the channel signal slot. The glue's + * `__deliver_pending_signal` (run right after the syscall returns) sees + * SIGKILL and calls the `kernel_exit` import directly (NOT musl `_exit()`, + * which would re-park the worker in the SYS_exit spin loop) → the `unreachable` + * trap that worker-main catches → the worker posts `{exit}` and returns to its + * JS event loop, where the host's `terminate()` (or the `{exit}` handler) can + * finally reclaim it. + * + * SIGKILL is never delivered to the guest in normal operation (it is + * uncatchable — the kernel enforces the default terminate action itself), so + * the glue treats a queued SIGKILL unambiguously as "exit now". + */ + killAllBlockedForTeardown(): Set { + // Drop all pending-retry bookkeeping first so nothing tries to re-arm a + // syscall behind the teardown. The actual wake is driven off the channels' + // CH_STATUS below, not off these maps — a worker parked on accept(), + // epoll_pwait(), a socket read, or a futex may not appear in any of these + // maps, but it is always sitting at CH_PENDING on its channel. + for (const e of this.pendingPollRetries.values()) if (e.timer) clearTimeout(e.timer); + for (const e of this.pendingSelectRetries.values()) if (e.timer) clearTimeout(e.timer); + for (const e of this.pendingSleeps.values()) clearTimeout(e.timer); + for (const e of this.pendingSignalWaits.values()) clearTimeout(e.timer); + this.pendingPipeReaders.clear(); + this.pendingPipeWriters.clear(); + this.pendingPollRetries.clear(); + this.pendingSelectRetries.clear(); + this.pendingSleeps.clear(); + this.pendingSignalWaits.clear(); + this.signalWaitDeadlines.clear(); + this.pendingFutexWaits.clear(); + + // Wake every channel (process main threads + pthreads) that is parked in + // Atomics.wait — i.e. status CH_PENDING — completing its syscall with + // -EINTR and queueing SIGKILL so the guest glue runs its cooperative exit. + // Returns the set of pids we actually woke so the caller can drain only for + // those (a not-woken straggler never posts {exit} and must be terminated + // directly, not waited on). + const woken = new Set(); + const getExitStatus = this.kernelInstance?.exports + .kernel_get_process_exit_status as ((pid: number) => number) | undefined; + for (const registration of this.processes.values()) { + // Skip processes that have already exited (kernel state == Exited, i.e. + // status != -1). A sibling thread may have called exit_group and set the + // process's real exit status while this thread is still parked; forcing + // our own kernel_exit on that parked thread would clobber that status + // (e.g. a pthread exit(0) turning into 137). Only genuinely-live processes + // need waking; already-exited stragglers are reaped/terminated normally. + if (getExitStatus && getExitStatus(registration.pid) !== -1) continue; + for (const channel of registration.channels) { + let status: number; + try { + const i32 = new Int32Array(channel.memory.buffer, channel.channelOffset); + status = Atomics.load(i32, CH_STATUS / 4); + } catch { continue; } + if (status !== CH_PENDING) continue; + try { + this.wakeChannelForTeardownExit(channel); + woken.add(channel.pid); + } catch (err) { + console.error(`[killAllBlockedForTeardown] wake failed for pid=${channel.pid} off=${channel.channelOffset}: ${err}`); + } + } + } + return woken; + } + + /** Complete a blocked channel with EINTR and queue SIGKILL so the guest glue + * runs its cooperative exit. See {@link killAllBlockedForTeardown}. + * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — see + * docs/jsc-terminate-atomics-wait-workaround.md. */ + private wakeChannelForTeardownExit(channel: ChannelInfo): void { + const pv = new DataView(channel.memory.buffer, channel.channelOffset); + // Queue SIGKILL for the glue's post-syscall __deliver_pending_signal. The + // syscall handlers may have called dequeueSignalForDelivery, but SIGKILL + // is never a queued Handler signal, so this slot is ours to set. Zero the + // handler slot too: SIGKILL is uncatchable, so it must never dispatch a + // userspace handler — the glue keys off signum==9 and exits before reading + // the handler, but clearing it keeps this write self-consistent. + pv.setUint32(CH_SIG_SIGNUM, SIGKILL, true); + pv.setUint32(CH_SIG_HANDLER, 0, true); + // Read the still-pending syscall request and complete it with -EINTR. + const syscallNr = pv.getUint32(CH_SYSCALL, true); + const origArgs: number[] = []; + for (let i = 0; i < CH_ARGS_COUNT; i++) { + origArgs.push(Number(pv.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true))); + } + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR_ERRNO); + } + + /** + * Schedule re-listen on a channel. + * + * Uses queueMicrotask for speed (near-zero delay between syscalls). + * Every Nth call (relistenBatchSize), yields via setImmediate so timer + * callbacks (setTimeout/setInterval) can fire — prevents event loop + * starvation while keeping throughput close to Node.js native setImmediate. + * + * The dedicated browser worker sets relistenBatchSize=1 so every relisten + * is deferred through its MessageChannel-backed setImmediate queue. This + * lets worker messages and timers interleave with multi-process syscall + * traffic. Node.js retains the larger native-setImmediate batch. + */ + private relistenCount = 0; + /** How many syscalls to process via microtask before yielding to the event + * loop via setImmediate. Default 64 is tuned for Node.js. The dedicated + * browser worker sets this to 1 so worker messages keep progressing. */ + relistenBatchSize = 64; + + /** + * When true, use a MessageChannel-based poller to check all channels + * instead of per-channel Atomics.waitAsync listeners. + * + * This avoids a V8 bug where Atomics.waitAsync microtask chains from + * multiple concurrent processes freeze the main thread. The poller + * uses MessageChannel for ~0ms dispatch (bypassing the browser's 4ms + * timer clamp on setTimeout/setInterval), with periodic setTimeout + * yields every 4ms to keep timers and rendering alive. + * + * This remains a legacy opt-in for browser embeddings that run the kernel + * on the main thread. The dedicated browser worker and Node.js both keep + * the default event-driven Atomics.waitAsync mode. + */ + usePolling = false; + private pollMC: MessageChannel | null = null; + private pollScheduled = false; + private pollLastYield = 0; + + /** Start the channel poller. Called automatically when usePolling=true + * and a process is registered. */ + private startPolling(): void { + if (this.pollMC !== null) return; + this.pollMC = new MessageChannel(); + this.pollMC.port1.onmessage = () => this.pollTick(); + this.pollLastYield = performance.now(); + this.schedulePoll(); + } + + /** Stop the channel poller. Called when all processes are unregistered. */ + private stopPolling(): void { + if (this.pollMC !== null) { + this.pollMC.port1.close(); + this.pollMC = null; + this.pollScheduled = false; + } + } + + /** Schedule the next poll tick. Uses MessageChannel for ~0ms dispatch, + * with a setTimeout yield every 4ms to prevent timer starvation. */ + private schedulePoll(): void { + if (this.pollScheduled || !this.pollMC) return; + this.pollScheduled = true; + const now = performance.now(); + if (now - this.pollLastYield >= 4) { + // Yield to timers/rendering + this.pollLastYield = now; + setTimeout(() => { + this.pollScheduled = false; + this.pollTick(); + }, 0); + } else { + this.pollMC.port2.postMessage(null); + } + } + + /** Poll all active channels for PENDING syscalls. */ + private pollTick(): void { + this.pollScheduled = false; + if (!this.pollMC || this.activeChannels.length === 0) return; + + // Snapshot to handle mutations during iteration (addChannel/removeChannel) + const channels = this.activeChannels.slice(); + for (const channel of channels) { + if (!this.isRegisteredChannel(channel)) continue; + if (this.stoppedPids?.has(channel.pid)) { + const stoppedView = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = stoppedView; + if (Atomics.load(stoppedView, CH_STATUS / 4) === CH_PENDING) { + this.deferChannelWhileStopped(channel); + } + continue; + } + if (channel.handling) continue; + // Re-create view in case memory was grown + const i32View = new Int32Array( + channel.memory.buffer, + channel.channelOffset, + ); + channel.i32View = i32View; + if (Atomics.load(i32View, 0) === CH_PENDING) { + channel.handling = true; + this.handleSyscall(channel); + } + } + + this.schedulePoll(); + } + + private relistenChannel(channel: ChannelInfo): void { + const parked = this.parkedChannelCompletions?.get(channel); + if (parked) { + parked.relistenRequested = true; + parked.prepared.relistenRequested = true; + channel.handling = true; + return; + } + if (this.deferChannelWhileStopped(channel)) return; + + // Clear handling flag so the poller can pick up this channel again + channel.handling = false; + if (!this.isRegisteredChannel(channel)) return; + // In polling mode, don't re-listen — the poller will pick up the next syscall + if (this.usePolling) return; + this.relistenCount++; + const useImmediate = this.relistenCount >= this.relistenBatchSize; + if (useImmediate) { + this.relistenCount = 0; + setImmediate(() => this.listenOnChannel(channel)); + } else { + queueMicrotask(() => this.listenOnChannel(channel)); + } + } + + /** + * Complete a channel with just return value and errno (no scatter/gather). + * Used for thread exit where we need to unblock the worker. + */ + private completeChannelRaw( + channel: ChannelInfo, + retVal: number, + errVal: number, + ): void { + this.clearSocketTimeout(channel); + this.clearReadinessWait(channel); + this.pendingCancels.delete(channel); + const prepared: PreparedChannelCompletion = { + kind: "raw", + outputWrites: [], + retVal, + errVal, + materialized: false, + // Raw callers preserve their existing explicit relisten decision. + relistenRequested: false, + }; + this.materializePreparedChannelCompletion(channel, prepared); + this.drainAndProcessWakeupEvents(); + this.publishOrParkChannelCompletion(channel, prepared); + } + + /** + * Handle EAGAIN retry for blocking syscalls. + * The process stays blocked while we retry asynchronously. + */ + private resolvePollReadinessIndices( + pid: number, + origArgs: number[], + ): { pipeIndices: number[]; acceptIndices: number[] } { + // Prefer kernel_get_fd_pipe_idx which handles both pipes AND sockets. + // Fall back to kernel_get_socket_recv_pipe for older kernels. + const getFdPipeIdx = this.kernelInstance!.exports.kernel_get_fd_pipe_idx as + ((pid: number, fd: number) => number) | undefined; + const getRecvPipe = + getFdPipeIdx ?? + (this.kernelInstance!.exports.kernel_get_socket_recv_pipe as + ((pid: number, fd: number) => number) | undefined); + const getAcceptWakeIdx = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getRecvPipe && !getAcceptWakeIdx) + return { pipeIndices: [], acceptIndices: [] }; + + const fdsPtr = origArgs[0]; + const nfds = origArgs[1]; + if (fdsPtr === 0 || nfds === 0) + return { pipeIndices: [], acceptIndices: [] }; + + // Find the channel for this pid to read process memory + const channel = this.activeChannels.find((c) => c.pid === pid); + if (!channel) return { pipeIndices: [], acceptIndices: [] }; + + const indices: number[] = []; + const acceptIndices: number[] = []; + const processMem = new DataView(channel.memory.buffer); + const POLLIN = 0x001; + // struct pollfd: fd(4) + events(2) + revents(2) = 8 bytes + for (let i = 0; i < nfds; i++) { + const fd = processMem.getInt32(fdsPtr + i * 8, true); + if (fd < 0) continue; + const events = processMem.getInt16(fdsPtr + i * 8 + 4, true); + if (getRecvPipe) { + const pipeIdx = getRecvPipe(pid, fd); + if (pipeIdx >= 0) { + indices.push(pipeIdx); + } + } + if (getAcceptWakeIdx && (events & POLLIN) !== 0) { + const acceptIdx = getAcceptWakeIdx(pid, fd); + if (acceptIdx >= 0) { + acceptIndices.push(acceptIdx); + } + } + } + return { pipeIndices: indices, acceptIndices }; + } + + private resolveEpollReadinessIndices(pid: number): { + pipeIndices: number[]; + acceptIndices: number[]; + } { + const getRecvPipe = this.kernelInstance!.exports + .kernel_get_socket_recv_pipe as + ((pid: number, fd: number) => number) | undefined; + const getAcceptWakeIdx = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (!getRecvPipe && !getAcceptWakeIdx) + return { pipeIndices: [], acceptIndices: [] }; + + const key = `${pid}:`; + const indices: number[] = []; + const acceptIndices: number[] = []; + const EPOLLIN = 0x001; + for (const [k, interests] of this.epollInterests) { + if (!k.startsWith(key)) continue; + for (const interest of interests) { + if (getRecvPipe) { + const pipeIdx = getRecvPipe(pid, interest.fd); + if (pipeIdx >= 0) { + indices.push(pipeIdx); + } + } + if (getAcceptWakeIdx && (interest.events & EPOLLIN) !== 0) { + const acceptIdx = getAcceptWakeIdx(pid, interest.fd); + if (acceptIdx >= 0) { + acceptIndices.push(acceptIdx); + } + } + } + } + return { pipeIndices: indices, acceptIndices }; + } + + private wakeBlockedAccept(acceptIdx: number): void { + const matches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => e.acceptIndices?.includes(acceptIdx), + ); + for (const [key, entry] of matches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + if (entry.timer !== null) { + clearTimeout(entry.timer); + } + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + } + } + + private wakeBlockedPoll(pid: number, pipeIdx: number): void { + // retrySyscall runs handleSyscall synchronously, which can re-insert + // the same key via pendingPollRetries.set when the kernel returns + // EAGAIN. JS Map iterators are not snapshots — re-inserted entries + // appear at the new tail and the iterator yields them, livelocking + // wakeBlockedPoll-hit / poll / poll-register inside one tick. Mirror + // wakeAllBlockedRetries' snapshot-and-skip-if-replaced pattern. + const matches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => e.channel.pid === pid && e.pipeIndices.includes(pipeIdx), + ); + for (const [key, entry] of matches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + if (entry.timer !== null) { + clearTimeout(entry.timer); + } + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + } + } + + /** + * Public wake helper for host-side pipe writes (TCP bridges, HTTP + * bridges, etc.). Call this AFTER directly writing into a pipe via + * `kernel_pipe_write` or `kernel_inject_connection`. + * + * In order: + * 1. Wake any process blocked in read/recv on this pipe + * (`pendingPipeReaders`). + * 2. Wake any process blocked in poll/ppoll/pselect6 whose + * `pipeIndices` includes this pipe (`pendingPollRetries`). + * Pass `pidFilter` only when ownership cannot be shared. Accepted TCP + * pipes omit it because fork children can inherit the same connection. + * 3. Schedule a broad wake (`scheduleWakeBlockedRetries`) for + * everything else. + * + * Without step 2, blocked pollers wait for the fallback timer in + * `handleBlockingRetry` to fire, which is the bug behind PR fixing + * the WordPress LAMP demo's slow install.php (see commit history). + */ + public notifyPipeReadable(pipeIdx: number, pidFilter?: number): void { + // 1. Blocked readers + const readers = this.pendingPipeReaders.get(pipeIdx); + if (readers && readers.length > 0) { + this.pendingPipeReaders.delete(pipeIdx); + for (const reader of readers) { + if (this.isRegisteredChannel(reader.channel)) { + this.retrySyscall(reader.channel); + } + } + } + // 2. Blocked pollers watching this pipe. Snapshot-and-skip-if-replaced: + // retrySyscall runs synchronously and a re-parking wait re-inserts the + // same exact-channel key, which a raw for..of over the live Map would + // revisit forever (see wakeBlockedPoll / sendSignalToProcess). + const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => + (pidFilter === undefined || e.channel.pid === pidFilter) && + e.pipeIndices.includes(pipeIdx), + ); + for (const [key, entry] of pollMatches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + if (entry.timer !== null) clearTimeout(entry.timer); + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + } + // 3. Broad wake for any other pending retries + this.scheduleWakeBlockedRetries(); + } + + /** + * Public wake helper for host-side pipe reads (response pump in + * the TCP/HTTP bridges). Call this AFTER directly reading data + * from a pipe so any process blocked writing because the pipe was + * full can resume, plus a broad wake. + */ + public notifyPipeWritable(pipeIdx: number): void { + const writers = this.pendingPipeWriters.get(pipeIdx); + if (writers && writers.length > 0) { + this.pendingPipeWriters.delete(pipeIdx); + for (const writer of writers) { + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + } + } + this.scheduleWakeBlockedRetries(); + } + + /** Cancel all pending poll retries for a given pid (used during cleanup) */ + private cleanupPendingPollRetries(pid: number): void { + for (const [key, entry] of this.pendingPollRetries) { + if (entry.channel.pid === pid) { + if (entry.timer) clearTimeout(entry.timer); + this.pendingPollRetries.delete(key); + } + } + } + + /** Cancel all pending select/pselect retries for a given pid. */ + private cleanupPendingSelectRetries(pid: number): void { + for (const [key, entry] of this.pendingSelectRetries) { + if (entry.channel.pid === pid) { + if (entry.timer !== null) { + clearTimeout(entry.timer); + clearImmediate(entry.timer); + } + this.pendingSelectRetries.delete(key); + } + } + } + + /** + * Drain kernel wakeup events and process pipe/listener/datagram wakeups. + * Called after each syscall completion. The kernel pushes events from + * PipeBuffer operations, listener backlog changes, and datagram send-state + * changes such as capacity, association, shutdown, close, or unlink. + */ + private drainAndProcessWakeupEvents(): void { + const drainFn = this.kernelInstance!.exports.kernel_drain_wakeup_events as + | ((outPtr: KernelPointer, outLen: number, maxEvents: number) => number) + | undefined; + if (!drainFn) return; + + const MAX_EVENTS = 256; + const BYTES_PER_EVENT = 5; + const bufSize = MAX_EVENTS * BYTES_PER_EVENT; + + // Own the complete batch before acting on any event. STOPPED/CONTINUED + // processing can send SIGCHLD and complete a parent wait, both of which + // reuse this scratch allocation. + const events: OwnedKernelWakeEvent[] = []; + for (;;) { + const count = drainFn( + this.toKernelPtr(this.scratchOffset), + bufSize, + MAX_EVENTS, + ); + if (count <= 0) break; + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + for (let i = 0; i < count; i++) { + const off = this.scratchOffset + i * BYTES_PER_EVENT; + events.push({ + wakeIdx: + (kernelMem[off] | + (kernelMem[off + 1] << 8) | + (kernelMem[off + 2] << 16) | + (kernelMem[off + 3] << 24)) >>> + 0, + wakeType: kernelMem[off + 4], + }); + } + if (count < MAX_EVENTS) break; + } + if (events.length === 0) return; + + const WAKE_READABLE = 1; + const WAKE_WRITABLE = 2; + const WAKE_ACCEPT = 4; + const WAKE_DATAGRAM_WRITABLE = 8; + let needBroadWake = false; + let needDatagramWriterWake = false; + + for (const { wakeIdx, wakeType } of events) { + const lifecycleEvent = + wakeType & (WAKE_PROCESS_STOPPED | WAKE_PROCESS_CONTINUED); + const lifecycleSupersededByExit = + lifecycleEvent !== 0 && + this.finalizeExitedProcessBeforeLifecycleNotification(wakeIdx); + + if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_STOPPED) { + (this.stoppedPids ??= new Set()).add(wakeIdx); + this.notifyParentOfChildStateTransition(wakeIdx); + } + + if (!lifecycleSupersededByExit && wakeType & WAKE_PROCESS_CONTINUED) { + if (this.resumeStoppedProcess(wakeIdx)) { + this.notifyParentOfChildStateTransition(wakeIdx); + } else { + // Resume-time delivery can immediately apply a retained default + // stop and enqueue a new STOPPED wake after this method owned its + // initial scratch batch. Drain that follow-up now; host-originated + // SIGCONT has no guaranteed later syscall completion to do it. + this.drainAndProcessWakeupEvents(); + } + } + + if (wakeType & WAKE_READABLE) { + // Pipe became readable — wake pending readers on this pipe + const readers = this.pendingPipeReaders.get(wakeIdx); + if (readers && readers.length > 0) { + this.pendingPipeReaders.delete(wakeIdx); + for (const reader of readers) { + if (this.isRegisteredChannel(reader.channel)) { + this.retrySyscall(reader.channel); + } + } + } + } + + if (wakeType & WAKE_WRITABLE) { + // Pipe became writable — wake pending writers on this pipe + const writers = this.pendingPipeWriters.get(wakeIdx); + if (writers && writers.length > 0) { + this.pendingPipeWriters.delete(wakeIdx); + for (const writer of writers) { + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + } + } + } + + if (wakeType & WAKE_ACCEPT) { + this.wakeBlockedAccept(wakeIdx); + } + + if (wakeType & WAKE_DATAGRAM_WRITABLE) { + // Datagram queues have no pipe token that identifies every blocked + // sender. Retry generic blocked writes synchronously so a short + // SO_SNDTIMEO cannot win after the send has become ready or acquired + // an immediate error. Poll, select, and epoll still use the broad path + // below so ppoll/pselect's deliberate signal-safe wake deferral + // remains intact. + needDatagramWriterWake = true; + } + + if ( + wakeType & + (WAKE_READABLE | WAKE_WRITABLE | WAKE_ACCEPT | WAKE_DATAGRAM_WRITABLE) + ) { + needBroadWake = true; + } + } + + // Any kernel readiness event may affect poll/select retries. + // + // If any of those retries is a signal-mask-swapping ppoll/pselect6, + // defer the wake a few ms. A pipe write from process X is often + // immediately followed by a cross-process signal (kill) from X — + // e.g. "write to pipe, then kill parent" — where the writer expects + // a blocked ppoll in the reader to observe BOTH events atomically. + // On a real kernel that works because X's two syscalls execute + // before the scheduler runs the reader. In our retry-based + // shared kernel, the pipe wakeup can fire a ppoll retry BEFORE + // X's follow-up kill is even sent by X's worker (Atomics.notify → + // uv_async round-trip takes 1–5ms). If the retry fires first, ppoll + // returns POLLIN and restores its sigmask; the late signal is then + // blocked and the handler never fires. See + // tests/sortix/os-test/signal/ppoll-block-sleep-write-raise. + // + // Deferring the broad wake a few ms gives X's follow-up syscalls + // time to land. Kill-triggered wakes (line ~2050) always use the + // immediate setImmediate path — by the time kill has been processed + // the signal is already queued, so there's no race. Pipe + // reader/writer wakes above run synchronously (not via this + // deferred path), so plain read/write throughput is unaffected. We + // only pay the delay when a pipe event happens to wake a ppoll or + // pselect6 caller. + if (needDatagramWriterWake) { + this.wakeBlockedFallbackWriters(); + } + if (needBroadWake) { + if (this.anyPendingRetryNeedsSignalSafeWake()) { + this.scheduleWakeBlockedRetriesDeferred(); + } else { + this.scheduleWakeBlockedRetries(); + } + } + } + + /** STOPPED/CONTINUED are waitable even when SA_NOCLDSTOP suppresses SIGCHLD. */ + private notifyParentOfChildStateTransition(pid: number): void { + const parentPid = this.getParentPid(pid); + if (parentPid === undefined) return; + + const hasNoCldStop = this.kernelInstance!.exports + .kernel_has_sa_nocldstop as (pid: number) => number; + if (hasNoCldStop(parentPid) !== 1) { + this.sendSignalToProcess(parentPid, SIGCHLD); + } else { + // SA_NOCLDSTOP suppresses only SIGCHLD generation. The status record is + // still waitable and must wake a matching wait4/waitid caller. + this.wakeWaitingParent(parentPid); + } + } + + /** Retry write-like fallback entries that have no targetable pipe token. */ + private wakeBlockedFallbackWriters(): void { + const matches = Array.from(this.pendingPollRetries.entries()).filter( + ([, entry]) => entry.isWriteRetry, + ); + for (const [key, entry] of matches) { + if (this.pendingPollRetries.get(key) !== entry) continue; + this.pendingPollRetries.delete(key); + if (entry.timer !== null) clearTimeout(entry.timer); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + } + } + + private anyPendingRetryNeedsSignalSafeWake(): boolean { + for (const entry of this.pendingPollRetries.values()) { + if (entry.needsSignalSafeWake) return true; + } + for (const entry of this.pendingSelectRetries.values()) { + if (entry.needsSignalSafeWake) return true; + } + return false; + } + + /** Same as scheduleWakeBlockedRetries but delays by a few ms to allow + * follow-up cross-process syscalls from the event source to land. */ + private scheduleWakeBlockedRetriesDeferred(): void { + if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return; + this.postponeSignalSafePollRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); + this.postponeSignalSafeSelectRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); + if (this.wakeScheduled) return; + this.wakeScheduled = true; + setTimeout(() => { + this.wakeScheduled = false; + this.wakeAllBlockedRetries(); + }, SIGNAL_SAFE_POLL_WAKE_DELAY_MS); + } + + private postponeSignalSafePollRetries(delayMs: number): void { + const now = Date.now(); + for (const [key, entry] of this.pendingPollRetries) { + if (!entry.needsSignalSafeWake) continue; + if (entry.timer !== null) { + clearTimeout(entry.timer); + } + + const remainingMs = entry.deadline && entry.deadline > 0 + ? Math.max(1, entry.deadline - now) + : delayMs; + const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); + entry.timer = setTimeout(() => { + if (this.pendingPollRetries.get(key) !== entry) return; + this.pendingPollRetries.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } + }, retryMs); + } + } + + /** Keep pselect's fallback timer from bypassing the signal-safe wake grace. */ + private postponeSignalSafeSelectRetries(delayMs: number): void { + const now = Date.now(); + for (const [key, entry] of this.pendingSelectRetries) { + if (!entry.needsSignalSafeWake) continue; + if (entry.timer !== null) { + clearTimeout(entry.timer); + clearImmediate(entry.timer); + } + + const remainingMs = entry.deadline > 0 + ? Math.max(1, entry.deadline - now) + : delayMs; + const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); + entry.timer = setTimeout(() => { + if (this.pendingSelectRetries.get(key) !== entry) return; + this.pendingSelectRetries.delete(key); + if (!this.isRegisteredChannel(entry.channel)) return; + if (entry.syscallNr === SYS_SELECT) { + this.handleSelect(entry.channel, entry.origArgs); + } else { + this.handlePselect6(entry.channel, entry.origArgs); + } + }, retryMs); + } + } + + /** + * Schedule a microtask to wake all blocked poll/pselect6 retries. + * Coalesced via wakeScheduled flag — multiple calls within the same + * microtask batch result in only one wake cycle. This catches cross-process + * pipe writes, socket connections, and other state changes that unblock + * another process's pending poll/select. + */ + private scheduleWakeBlockedRetries(): void { + if (this.wakeScheduled) return; + if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return; + this.wakeScheduled = true; + // Use setImmediate (not queueMicrotask) so that timer callbacks + // (setTimeout/setInterval) can interleave. In browsers, microtask + // chains from queueMicrotask starve all macrotasks, breaking progress + // updates and timeouts. setImmediate goes through the polyfill which + // yields to the timer queue periodically. + setImmediate(() => { + this.wakeScheduled = false; + this.wakeAllBlockedRetries(); + }); + } + + /** + * Wake all blocked poll/pselect6 retries by cancelling their setImmediate + * timers and immediately re-executing the syscalls. + */ + private wakeAllBlockedRetries(): void { + // Snapshot and clear — retries may re-add themselves if still not ready + const pollEntries = Array.from(this.pendingPollRetries.entries()); + const selectEntries = Array.from(this.pendingSelectRetries.entries()); + this.pendingPollRetries.clear(); + this.pendingSelectRetries.clear(); + + for (const [_key, entry] of pollEntries) { + if (!this.isRegisteredChannel(entry.channel)) continue; + if (entry.timer !== null) { + clearTimeout(entry.timer); + } + this.retrySyscall(entry.channel); + } + + for (const [, entry] of selectEntries) { + if (!this.isRegisteredChannel(entry.channel)) continue; + // Cancel both setTimeout and setImmediate handles (one will be a no-op) + clearTimeout(entry.timer); + clearImmediate(entry.timer); + // Re-dispatch to the right handler — SYS_SELECT and SYS_PSELECT6 have + // different time-struct shapes (timeval vs timespec). + if (entry.syscallNr === SYS_SELECT) { + this.handleSelect(entry.channel, entry.origArgs); + } else { + this.handlePselect6(entry.channel, entry.origArgs); + } + } + + // Also wake all pending pipe readers — a cross-process write may have + // made data available on pipes that readers are waiting on. + if (this.pendingPipeReaders.size > 0) { + const pipeEntries = Array.from(this.pendingPipeReaders.entries()); + this.pendingPipeReaders.clear(); + for (const [, readers] of pipeEntries) { + for (const reader of readers) { + if (this.isRegisteredChannel(reader.channel)) { + this.retrySyscall(reader.channel); + } + } + } + } + + // Also wake all pending pipe writers — a cross-process read may have + // drained pipe buffer space that writers are waiting on. + if (this.pendingPipeWriters.size > 0) { + const writerEntries = Array.from(this.pendingPipeWriters.entries()); + this.pendingPipeWriters.clear(); + for (const [, writers] of writerEntries) { + for (const writer of writers) { + if (this.isRegisteredChannel(writer.channel)) { + this.retrySyscall(writer.channel); + } + } + } + } + } + + /** + * Remove a process's entries from pendingPipeReaders. + * Called during process cleanup. + */ + private cleanupPendingPipeReaders(pid: number): void { + for (const [pipeIdx, readers] of this.pendingPipeReaders) { + const filtered = readers.filter(r => r.pid !== pid); + if (filtered.length === 0) { + this.pendingPipeReaders.delete(pipeIdx); + } else { + this.pendingPipeReaders.set(pipeIdx, filtered); + } + } + } + + private cleanupPendingPipeWriters(pid: number): void { + for (const [pipeIdx, writers] of this.pendingPipeWriters) { + const filtered = writers.filter(w => w.pid !== pid); + if (filtered.length === 0) { + this.pendingPipeWriters.delete(pipeIdx); + } else { + this.pendingPipeWriters.set(pipeIdx, filtered); + } + } + } + + /** + * Cancel a pending socket timeout timer for a channel. + */ + private clearSocketTimeout(channel: ChannelInfo): void { + const timer = this.socketTimeoutTimers.get(channel); + if (timer !== undefined) { + clearTimeout(timer); + this.socketTimeoutTimers.delete(channel); + } + } + + /** Reuse one absolute deadline across readiness retries for this syscall. */ + private getReadinessDeadline(channel: ChannelInfo, timeoutMs: number): number { + if (timeoutMs <= 0) return -1; + if (channel.readinessDeadline === undefined) { + channel.readinessDeadline = Date.now() + timeoutMs; + } + return channel.readinessDeadline; + } + + /** Clear readiness deadline and any still-parked retry for a completed call. */ + private clearReadinessWait(channel: ChannelInfo): void { + channel.readinessDeadline = undefined; + channel.readinessFinalCheck = undefined; + + const pollEntry = this.pendingPollRetries.get(channel); + if (pollEntry) { + if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); + this.pendingPollRetries.delete(channel); + } + + const selectEntry = this.pendingSelectRetries.get(channel); + if (selectEntry) { + if (selectEntry.timer !== null) { + clearTimeout(selectEntry.timer); + clearImmediate(selectEntry.timer); + } + this.pendingSelectRetries.delete(channel); + } + } + + /** + * Remove a channel from pending pipe readers (all pipes). + * Called when a socket timeout fires to clean up the reader registration. + */ + private removePendingPipeReader(channel: ChannelInfo): void { + if (!this.pendingPipeReaders) return; + for (const [pipeIdx, readers] of this.pendingPipeReaders) { + const filtered = readers.filter((r) => r.channel !== channel); + if (filtered.length === 0) { + this.pendingPipeReaders.delete(pipeIdx); + } else if (filtered.length !== readers.length) { + this.pendingPipeReaders.set(pipeIdx, filtered); + } + } + } + + /** + * Remove a channel from pending pipe writers (all pipes). + */ + private removePendingPipeWriter(channel: ChannelInfo): void { + if (!this.pendingPipeWriters) return; + for (const [pipeIdx, writers] of this.pendingPipeWriters) { + const filtered = writers.filter((w) => w.channel !== channel); + if (filtered.length === 0) { + this.pendingPipeWriters.delete(pipeIdx); + } else if (filtered.length !== writers.length) { + this.pendingPipeWriters.set(pipeIdx, filtered); + } + } + } + + /** + * SYS_THREAD_CANCEL — wake a thread that is blocked in a cancellation-point + * syscall so its glue (__syscall_cp) can observe the pending cancel flag + * and run pthread_exit(PTHREAD_CANCELED). + * + * The guest pthread_cancel() overlay has already atomically set + * target->cancel = 1 in shared memory before calling this syscall — see + * libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c for the full flow. + * + * This handler's sole job is to force the target out of its Atomics.wait32 + * on CH_STATUS (if blocked). Strategy depends on what the target is + * waiting on: + * + * - futex wait: fire Atomics.notify on the futex address. handleFutex's + * waitAsync Promise resolves, writes (0, 0) to the channel, target + * wakes. Return-value 0 is benign — the post-syscall __testcancel() + * in glue picks up self->cancel and exits before the caller re-checks + * its predicate. + * - pipe read/write blocked on pendingPipeReaders/Writers: remove the + * registration and complete the channel with -EINTR. + * - poll/select scheduled with a retry timer: clear the timer and + * complete with -EINTR. + * - otherwise (not blocked, or already completed): no-op. The target + * will observe self->cancel on its next cancel-point entry. + * + * The caller's own syscall always succeeds with 0. + */ + private handleThreadCancel(channel: ChannelInfo, origArgs: number[]): void { + const targetTid = origArgs[0]; + const registration = this.processes.get(channel.pid); + + // Always complete the caller's syscall first so pthread_cancel returns. + this.completeChannelRaw(channel, 0, 0); + this.relistenChannel(channel); + + if (!registration) return; + + // Resolve target channel: main thread has tid == pid; other threads are + // tracked in channelTids by their clone-assigned tid. + let target: ChannelInfo | undefined; + for (const ch of registration.channels) { + const mappedTid = this.channelTids.get(`${channel.pid}:${ch.channelOffset}`); + const effectiveTid = mappedTid !== undefined ? mappedTid : channel.pid; + if (effectiveTid === targetTid) { + target = ch; + break; + } + } + if (!target) return; - // Set status to COMPLETE and notify process - const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); - Atomics.store(i32View, CH_STATUS / 4, CH_COMPLETE); - Atomics.notify(i32View, CH_STATUS / 4, 1); + // Arm the host-side pre-enqueue guard used by wait and futex. The guest + // pthread_t cancel bit remains authoritative for untracked operations and + // is checked by __syscall_cp before/after their next cancellation point. + this.pendingCancels.add(target); + // If the target has already parked in a tracked blocking wait, wake + // it so its natural completion path runs and the guest sees the + // cancel in __syscall_cp_check. Doing the wake via the same mechanism + // the wait uses (Atomics.notify on the futex addr, cancelling the + // retry timer, etc.) avoids racing against the handler's own + // completion path — we never write the channel directly here. - // Drain kernel wakeup events and process targeted wakeups. - this.drainAndProcessWakeupEvents(); + // 1) Futex wait — Atomics.notify wakes the in-flight waitAsync, which + // calls complete() and completeChannelRaw naturally. + const futexEntry = this.pendingFutexWaits.get(target); + if (futexEntry) { + if (futexEntry.interrupt) { + futexEntry.interrupt(-EINTR_ERRNO, EINTR_ERRNO); + } else { + const tgtMemView = new Int32Array(target.memory.buffer); + Atomics.notify(tgtMemView, futexEntry.futexIndex, 1); + } + return; + } - // Re-listen for next syscall - this.relistenChannel(channel); - } + // 2) Poll/ppoll retry timer — retire the tracked retry and complete the + // exact cancellation point with EINTR. + const pollEntry = this.pendingPollRetries.get(target); + if (pollEntry) { + if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); + this.pendingPollRetries.delete(target); + this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(target); + return; + } - /** - * Host-teardown reclamation. - * - * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — WORKAROUND, remove when the engine bug - * is fixed; see docs/jsc-terminate-atomics-wait-workaround.md. - * - * On JSC (Safari, and Bun via `bun`'s JavaScriptCore), `Worker.terminate()` - * cannot kill (or free the memory of) a worker parked in `Atomics.wait` on - * its syscall channel — which is where every idle/blocked process worker sits - * (accept, read, poll, select, sleep, futex, the channel round-trip). - * Terminating them directly leaks their threads + committed working set, so - * each image switch accumulates a whole machine and the tab OOMs. V8 (Chrome, - * Node) interrupts the wait on terminate and reclaims, so this is a no-op cost - * there and is invoked unconditionally by both host entries for parity. - * - * For every worker currently parked at CH_PENDING we complete its syscall - * with EINTR AND queue a SIGKILL into the channel signal slot. The glue's - * `__deliver_pending_signal` (run right after the syscall returns) sees - * SIGKILL and calls the `kernel_exit` import directly (NOT musl `_exit()`, - * which would re-park the worker in the SYS_exit spin loop) → the `unreachable` - * trap that worker-main catches → the worker posts `{exit}` and returns to its - * JS event loop, where the host's `terminate()` (or the `{exit}` handler) can - * finally reclaim it. - * - * SIGKILL is never delivered to the guest in normal operation (it is - * uncatchable — the kernel enforces the default terminate action itself), so - * the glue treats a queued SIGKILL unambiguously as "exit now". - */ - killAllBlockedForTeardown(): Set { - // Drop all pending-retry bookkeeping first so nothing tries to re-arm a - // syscall behind the teardown. The actual wake is driven off the channels' - // CH_STATUS below, not off these maps — a worker parked on accept(), - // epoll_pwait(), a socket read, or a futex may not appear in any of these - // maps, but it is always sitting at CH_PENDING on its channel. - for (const e of this.pendingPollRetries.values()) if (e.timer) clearTimeout(e.timer); - for (const e of this.pendingSelectRetries.values()) if (e.timer) clearTimeout(e.timer); - for (const e of this.pendingSleeps.values()) clearTimeout(e.timer); - this.pendingPipeReaders.clear(); - this.pendingPipeWriters.clear(); - this.pendingPollRetries.clear(); - this.pendingSelectRetries.clear(); - this.pendingSleeps.clear(); - this.pendingFutexWaits.clear(); + // 3) Select/pselect retry timer. + const selEntry = this.pendingSelectRetries.get(target); + if (selEntry) { + clearTimeout(selEntry.timer); + clearImmediate(selEntry.timer); + this.pendingSelectRetries.delete(target); + this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(target); + return; + } - // Wake every channel (process main threads + pthreads) that is parked in - // Atomics.wait — i.e. status CH_PENDING — completing its syscall with - // -EINTR and queueing SIGKILL so the guest glue runs its cooperative exit. - // Returns the set of pids we actually woke so the caller can drain only for - // those (a not-woken straggler never posts {exit} and must be terminated - // directly, not waited on). - const woken = new Set(); - const getExitStatus = this.kernelInstance?.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - for (const registration of this.processes.values()) { - // Skip processes that have already exited (kernel state == Exited, i.e. - // status != -1). A sibling thread may have called exit_group and set the - // process's real exit status while this thread is still parked; forcing - // our own kernel_exit on that parked thread would clobber that status - // (e.g. a pthread exit(0) turning into 137). Only genuinely-live processes - // need waking; already-exited stragglers are reaped/terminated normally. - if (getExitStatus && getExitStatus(registration.pid) !== -1) continue; - for (const channel of registration.channels) { - let status: number; - try { - const i32 = new Int32Array(channel.memory.buffer, channel.channelOffset); - status = Atomics.load(i32, CH_STATUS / 4); - } catch { continue; } - if (status !== CH_PENDING) continue; - try { - this.wakeChannelForTeardownExit(channel); - woken.add(channel.pid); - } catch (err) { - console.error(`[killAllBlockedForTeardown] wake failed for pid=${channel.pid} off=${channel.channelOffset}: ${err}`); - } + // 4) Pipe/socket reader/writer registration — unregister and wake. + let wokePipe = false; + for (const [pipeIdx, readers] of this.pendingPipeReaders) { + const filtered = readers.filter(r => r.channel !== target); + if (filtered.length !== readers.length) { + if (filtered.length === 0) this.pendingPipeReaders.delete(pipeIdx); + else this.pendingPipeReaders.set(pipeIdx, filtered); + wokePipe = true; } } - return woken; - } + for (const [pipeIdx, writers] of this.pendingPipeWriters) { + const filtered = writers.filter(w => w.channel !== target); + if (filtered.length !== writers.length) { + if (filtered.length === 0) this.pendingPipeWriters.delete(pipeIdx); + else this.pendingPipeWriters.set(pipeIdx, filtered); + wokePipe = true; + } + } + if (wokePipe) { + this.clearSocketTimeout(target); + this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(target); + return; + } - /** Complete a blocked channel with EINTR and queue SIGKILL so the guest glue - * runs its cooperative exit. See {@link killAllBlockedForTeardown}. - * [JSC-TERMINATE-ATOMICS-WAIT-LEAK] — see - * docs/jsc-terminate-atomics-wait-workaround.md. */ - private wakeChannelForTeardownExit(channel: ChannelInfo): void { - const pv = new DataView(channel.memory.buffer, channel.channelOffset); - // Queue SIGKILL for the glue's post-syscall __deliver_pending_signal. The - // syscall handlers may have called dequeueSignalForDelivery, but SIGKILL - // is never a queued Handler signal, so this slot is ours to set. Zero the - // handler slot too: SIGKILL is uncatchable, so it must never dispatch a - // userspace handler — the glue keys off signum==9 and exits before reading - // the handler, but clearing it keeps this write self-consistent. - pv.setUint32(CH_SIG_SIGNUM, SIGKILL, true); - pv.setUint32(CH_SIG_HANDLER, 0, true); - // Read the still-pending syscall request and complete it with -EINTR. - const syscallNr = pv.getUint32(CH_SYSCALL, true); - const origArgs: number[] = []; - for (let i = 0; i < CH_ARGS_COUNT; i++) { - origArgs.push(Number(pv.getBigInt64(CH_ARGS + i * CH_ARG_SIZE, true))); + // 5) wait()/waitpid()/wait4()/waitid() are cancellation points in musl. + // Remove the exact host-owned waiter before waking its channel so a later + // child transition cannot complete a canceled thread's reused mailbox. + const waitIndex = this.waitingForChild.findIndex( + (waiter) => waiter.channel === target, + ); + if (waitIndex >= 0) { + this.waitingForChild.splice(waitIndex, 1); + this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(target); + return; } - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR_ERRNO); + + // 6) No tracked blocking state — the target either hasn't reached the + // blocking entry yet, or its handler is synchronous and will pick + // up pendingCancels the next time it enters a blocking operation. + // Do NOT write the channel here: the in-flight handleSyscall owns + // it and would race with our completeChannelRaw. } /** - * Schedule re-listen on a channel. - * - * Uses queueMicrotask for speed (near-zero delay between syscalls). - * Every Nth call (relistenBatchSize), yields via setImmediate so timer - * callbacks (setTimeout/setInterval) can fire — prevents event loop - * starvation while keeping throughput close to Node.js native setImmediate. + * Dump syscall profiling data to stderr. Call from your serve script: + * process.on('SIGINT', () => { kernelWorker.dumpProfile(); process.exit(); }); * - * In the browser (main thread), set relistenBatchSize=1 so every syscall - * yields via setImmediate. The browser setImmediate polyfill (MessageChannel) - * batches these efficiently while still allowing rendering frames between - * batches. Without this, microtask chains from multi-threaded programs - * (e.g. MariaDB's 5 threads) starve requestAnimationFrame and rendering. + * Only produces output when WASM_POSIX_PROFILE=1 env var is set. */ - private relistenCount = 0; - /** How many syscalls to process via microtask before yielding to the event - * loop via setImmediate. Default 64 is optimal for Node.js. Set to 1 in - * browser environments where the kernel runs on the main thread. */ - relistenBatchSize = 64; + dumpProfile(): void { + if (!this.profileData) { + console.error('[profile] Profiling not enabled. Set WASM_POSIX_PROFILE=1'); + return; + } - /** - * When true, use a MessageChannel-based poller to check all channels - * instead of per-channel Atomics.waitAsync listeners. - * - * This avoids a V8 bug where Atomics.waitAsync microtask chains from - * multiple concurrent processes freeze the main thread. The poller - * uses MessageChannel for ~0ms dispatch (bypassing the browser's 4ms - * timer clamp on setTimeout/setInterval), with periodic setTimeout - * yields every 4ms to keep timers and rendering alive. - * - * Enable this in browser environments where the kernel runs on the - * main thread. In Node.js (where setImmediate is native), the default - * event-driven mode (Atomics.waitAsync) is preferred. - */ - usePolling = false; - private pollMC: MessageChannel | null = null; - private pollScheduled = false; - private pollLastYield = 0; + const entries = Array.from(this.profileData.entries()) + .sort((a, b) => b[1].totalTimeMs - a[1].totalTimeMs); - /** Start the channel poller. Called automatically when usePolling=true - * and a process is registered. */ - private startPolling(): void { - if (this.pollMC !== null) return; - this.pollMC = new MessageChannel(); - this.pollMC.port1.onmessage = () => this.pollTick(); - this.pollLastYield = performance.now(); - this.schedulePoll(); + let totalCalls = 0; + let totalTime = 0; + let totalRetries = 0; + + console.error('\n=== Syscall Profile ==='); + console.error(`${'Syscall'.padEnd(8)} ${'Count'.padStart(10)} ${'Time(ms)'.padStart(12)} ${'Avg(ms)'.padStart(10)} ${'Retries'.padStart(10)}`); + console.error('-'.repeat(52)); + + for (const [nr, data] of entries) { + totalCalls += data.count; + totalTime += data.totalTimeMs; + totalRetries += data.retries; + console.error( + `${String(nr).padEnd(8)} ${String(data.count).padStart(10)} ${data.totalTimeMs.toFixed(2).padStart(12)} ${(data.totalTimeMs / data.count).toFixed(3).padStart(10)} ${String(data.retries).padStart(10)}` + ); + } + + console.error('-'.repeat(52)); + console.error( + `${'TOTAL'.padEnd(8)} ${String(totalCalls).padStart(10)} ${totalTime.toFixed(2).padStart(12)} ${(totalTime / (totalCalls || 1)).toFixed(3).padStart(10)} ${String(totalRetries).padStart(10)}` + ); + console.error(`Pending pipe readers: ${this.pendingPipeReaders.size}, writers: ${this.pendingPipeWriters.size}`); + console.error('=== End Profile ===\n'); } - /** Stop the channel poller. Called when all processes are unregistered. */ - private stopPolling(): void { - if (this.pollMC !== null) { - this.pollMC.port1.close(); - this.pollMC = null; - this.pollScheduled = false; + private flushTcpSendPipes(pid: number): void { + const conns = this.tcpConnections.get(pid); + if (!conns || conns.length === 0) return; + + const pipeRead = this.kernelInstance!.exports.kernel_pipe_read as + (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; + const mem = this.getKernelMem(); + + // Injected-connection pipes live in the global pipe table; pid=0 + // tells kernel_pipe_read to use it directly. See kernel_inject_connection. + for (const conn of conns) { + // Drain all available data from the send pipe (not just one chunk) + for (;;) { + const readN = pipeRead(0, conn.sendPipeIdx, this.toKernelPtr(conn.scratchOffset), 65536); + if (readN <= 0) break; + const outData = Buffer.from(mem.slice(conn.scratchOffset, conn.scratchOffset + readN)); + if (!conn.clientSocket.destroyed) { + conn.clientSocket.write(outData); + } + } + // Schedule pump to detect pipe closure (PHP closing the socket) + conn.schedulePump(); } } - /** Schedule the next poll tick. Uses MessageChannel for ~0ms dispatch, - * with a setTimeout yield every 4ms to prevent timer starvation. */ - private schedulePoll(): void { - if (this.pollScheduled || !this.pollMC) return; - this.pollScheduled = true; - const now = performance.now(); - if (now - this.pollLastYield >= 4) { - // Yield to timers/rendering - this.pollLastYield = now; - setTimeout(() => { - this.pollScheduled = false; - this.pollTick(); - }, 0); + /** + * Route a host-delegated AF_INET connect that has not completed yet. + * + * The Rust kernel translates HostIO's internal EAGAIN sentinel into the + * connect(2) API's EINPROGRESS (first attempt) or EALREADY (repeat attempt). + * A non-blocking guest must observe that exact errno. A blocking guest must + * remain asleep while the host periodically re-enters the kernel to query + * the same connection; that retry never starts a second host connection. + */ + private handlePendingInetConnect( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + ): boolean { + if ( + syscallNr !== SYS_CONNECT || + retVal !== -1 || + (errVal !== EINPROGRESS && errVal !== EALREADY) + ) { + return false; + } + + const addrPtr = origArgs[1]; + const addrLen = origArgs[2]; + if ( + !Number.isSafeInteger(addrPtr) || + addrPtr <= 0 || + addrLen < 2 || + addrPtr + 2 > channel.memory.buffer.byteLength + ) { + return false; + } + const AF_INET = 2; + const family = new DataView(channel.memory.buffer).getUint16(addrPtr, true); + if (family !== AF_INET) return false; + + const isFdNonblock = this.kernelInstance!.exports.kernel_is_fd_nonblock as + ((pid: number, fd: number) => number) | undefined; + const nonblock = isFdNonblock?.(channel.pid, origArgs[0]) === 1; + if (nonblock) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + errVal, + ); } else { - this.pollMC.port2.postMessage(null); + this.handleBlockingRetry(channel, syscallNr, origArgs); } + return true; } - /** Poll all active channels for PENDING syscalls. */ - private pollTick(): void { - this.pollScheduled = false; - if (!this.pollMC || this.activeChannels.length === 0) return; + private handleBlockingRetry( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + ): void { + if (!this.isRegisteredChannel(channel)) return; - // Snapshot to handle mutations during iteration (addChannel/removeChannel) - const channels = this.activeChannels.slice(); - for (const channel of channels) { - if (channel.handling) continue; - // Re-create view in case memory was grown - const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); - channel.i32View = i32View; - if (Atomics.load(i32View, 0) === CH_PENDING) { - channel.handling = true; - this.handleSyscall(channel); - } - } + // Futex wait: use Atomics.waitAsync on the target address in process memory + if (syscallNr === SYS_FUTEX) { + const futexOp = origArgs[1] & 0x7f; // mask out FUTEX_PRIVATE_FLAG + if (futexOp === 0) { // FUTEX_WAIT + const addr = origArgs[0]; // address in process memory + const expectedVal = origArgs[2]; + const i32View = new Int32Array(channel.memory.buffer); + const index = addr >>> 2; // convert byte offset to i32 index - this.schedulePoll(); - } + // Check if value already changed + const currentVal = Atomics.load(i32View, index); + if (currentVal !== expectedVal) { + // Value changed, retry syscall immediately — kernel should succeed + this.retrySyscall(channel); + return; + } - private relistenChannel(channel: ChannelInfo): void { - // Clear handling flag so the poller can pick up this channel again - channel.handling = false; - if (!this.processes.has(channel.pid)) return; - // In polling mode, don't re-listen — the poller will pick up the next syscall - if (this.usePolling) return; - this.relistenCount++; - const useImmediate = this.relistenCount >= this.relistenBatchSize; - if (useImmediate) { - this.relistenCount = 0; - setImmediate(() => this.listenOnChannel(channel)); - } else { - queueMicrotask(() => this.listenOnChannel(channel)); + // Wait for value to change + const waitResult = Atomics.waitAsync(i32View, index, expectedVal); + if (waitResult.async) { + waitResult.value.then(() => { + if (this.isRegisteredChannel(channel)) { + this.retrySyscall(channel); + } + }); + } else { + // Already changed — use setImmediate (not queueMicrotask) to avoid + // microtask chains that starve the browser event loop. + setImmediate(() => this.retrySyscall(channel)); + } + return; + } } - } - /** - * Complete a channel with just return value and errno (no scatter/gather). - * Used for thread exit where we need to unblock the worker. - */ - private completeChannelRaw(channel: ChannelInfo, retVal: number, errVal: number): void { - // Clear handling flag (channel is done — poller can pick it up for next syscall) - channel.handling = false; + // Poll with timeout: the kernel did a non-blocking check and returned EAGAIN. + // We retry after a short delay. If poll has timeout=0 (EAGAIN means no events), + // we should return 0 immediately instead of retrying. + if (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) { + let timeoutMs = -1; + // PPOLL with a non-null sigmask pointer swaps the signal mask for the + // duration of the wait. Broad wakes from cross-process pipe writes + // need a short grace period for such callers so follow-up signals + // from the writer land before ppoll returns with fds ready. + const needsSignalSafeWake = syscallNr === SYS_PPOLL && origArgs[3] !== 0; + if (syscallNr === SYS_POLL) { + timeoutMs = origArgs[2]; // timeout in ms + } else { + const tsPtr = origArgs[2]; + if (tsPtr !== 0) { + const pv = new DataView(channel.memory.buffer, tsPtr); + const sec = Number(pv.getBigInt64(0, true)); + const nsec = Number(pv.getBigInt64(8, true)); + timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); + } + } + if (timeoutMs === 0) { + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); + return; + } + const deadline = this.getReadinessDeadline(channel, timeoutMs); + if (deadline > 0 && Date.now() >= deadline) { + // Re-enter once with timeout=0. Besides checking readiness at the + // deadline, this lets ppoll restore its temporary signal mask. + channel.readinessFinalCheck = true; + this.retrySyscall(channel); + return; + } - const processView = new DataView(channel.memory.buffer, channel.channelOffset); - processView.setBigInt64(CH_RETURN, BigInt(retVal), true); - processView.setUint32(CH_ERRNO, errVal, true); + // Resolve which pipe/listener readiness tokens the polled fds map to. + const { pipeIndices, acceptIndices } = + this.resolvePollReadinessIndices(channel.pid, origArgs); - // Cancel any pending socket timeout timer for this channel - this.clearSocketTimeout(channel); + // For finite timeout, track the deadline so we return 0 (timeout) when it + // expires instead of retrying forever. The nfds=0 case (pure sleep) is + // optimized to skip retries entirely — just wait for the deadline. + const nfds = origArgs[1]; // poll(fds, nfds, ...) / ppoll(fds, nfds, ...) + if (timeoutMs > 0 && nfds === 0) { + // Pure sleep: no fds to poll, just wait for timeout + const remainingMs = Math.max(deadline - Date.now(), 1); + const timer = setTimeout(() => { + if (this.pendingPollRetries.get(channel)?.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + channel.readinessFinalCheck = true; + this.retrySyscall(channel); + } + }, remainingMs); + this.pendingPollRetries.set(channel, { + timer, + channel, + pipeIndices, + acceptIndices, + needsSignalSafeWake, + deadline, + }); + return; + } - // Clear any one-shot pthread cancel flag: it was meant for the - // syscall we're completing now. Leaving it armed would let the next - // blocking entry spuriously EINTR even if no further cancel arrived. - this.pendingCancels.delete(channel.channelOffset); + const retryFn = () => { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; + // Always run the kernel once at the deadline. If it still reports + // EAGAIN, the branch above completes the wait with 0. + this.retrySyscall(channel); + }; + // With pipe/listener readiness tokens, rely on targeted wakeups for + // instant retry. The timer is only a safety net. + // The intended wakeup path is event-driven: + // drainAndProcessWakeupEvents → scheduleWakeBlockedRetries + // (setImmediate) retries the poll when any of its watched pipes + // changes state. The timer is a fallback. Empirically the + // 200 ms safety net was sleeping past wakeups in the browser + // worker (WordPress install.php measured 28 s with 200 ms, + // 1.9 s with 10 ms — a 14× difference far in excess of what + // 5 fallback fires can explain, suggesting setImmediate-based + // broad wakes occasionally lose against the timer in the + // browser's MessageChannel polyfill). 10 ms matches the default + // for read-like / write-like blocking retries. + const hasTargetedWake = pipeIndices.length > 0 || acceptIndices.length > 0; + const retryMs = hasTargetedWake + ? (deadline > 0 ? Math.min(deadline - Date.now(), 10) : 10) + : (deadline > 0 ? Math.min(deadline - Date.now(), 50) : 50); + const timer = setTimeout(retryFn, Math.max(retryMs, 1)); + this.pendingPollRetries.set(channel, { + timer, + channel, + pipeIndices, + acceptIndices, + needsSignalSafeWake, + deadline, + }); + return; + } - const i32View = new Int32Array(channel.memory.buffer, channel.channelOffset); - Atomics.store(i32View, CH_STATUS / 4, CH_COMPLETE); - Atomics.notify(i32View, CH_STATUS / 4, 1); - } + // (epoll_pwait is now handled entirely on the host side by handleEpollPwait) - /** - * Handle EAGAIN retry for blocking syscalls. - * The process stays blocked while we retry asynchronously. - */ - private resolvePollReadinessIndices( - pid: number, - origArgs: number[], - ): { pipeIndices: number[]; acceptIndices: number[] } { - // Prefer kernel_get_fd_pipe_idx which handles both pipes AND sockets. - // Fall back to kernel_get_socket_recv_pipe for older kernels. - const getFdPipeIdx = this.kernelInstance!.exports.kernel_get_fd_pipe_idx as - ((pid: number, fd: number) => number) | undefined; - const getRecvPipe = getFdPipeIdx ?? (this.kernelInstance!.exports.kernel_get_socket_recv_pipe as - ((pid: number, fd: number) => number) | undefined); - const getAcceptWakeIdx = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (!getRecvPipe && !getAcceptWakeIdx) return { pipeIndices: [], acceptIndices: [] }; + // sigtimedwait: kernel returned EAGAIN because no signal is pending. + // Instead of busy-retrying, delay for the requested timeout then complete + // with -1/EAGAIN. + if (syscallNr === SYS_RT_SIGTIMEDWAIT) { + const timeoutPtr = origArgs[2]; // pointer to timespec in process memory + if (timeoutPtr === 0) { + // NULL timeout = wait indefinitely. Use long retry interval since + // signals arrive via kernel_kill, not organically. In the browser, + // short retries starve the event loop when multiple threads are active + // (e.g. MariaDB's signal handler thread). Targeted signal paths wake + // this registration immediately; 500ms remains a safety net. + const key = `${channel.pid}:${channel.channelOffset}`; + const previous = this.pendingSignalWaits.get(key); + if (previous) clearTimeout(previous.timer); + const timer = setTimeout(() => { + this.pendingSignalWaits.delete(key); + if (this.isRegisteredChannel(channel)) { + this.retrySyscall(channel); + } + }, 500); + this.pendingSignalWaits.set(key, { timer, channel, origArgs }); + return; + } + const pv = new DataView(channel.memory.buffer, timeoutPtr); + // timespec: i64 sec + i64 nsec (time64) + const sec = Number(pv.getBigInt64(0, true)); + const nsec = Number(pv.getBigInt64(8, true)); + const timeoutMs = sec * 1000 + Math.floor(nsec / 1_000_000); + const EAGAIN_ERRNO = 11; + const key = `${channel.pid}:${channel.channelOffset}`; + if (timeoutMs <= 0) { + this.signalWaitDeadlines.delete(key); + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); + } else { + const existingDeadline = this.signalWaitDeadlines.get(key); + const deadline = existingDeadline?.deadline ?? performance.now() + timeoutMs; + if (!existingDeadline) { + this.signalWaitDeadlines.set(key, { pid: channel.pid, deadline }); + } + const remainingMs = deadline - performance.now(); + if (remainingMs <= 0) { + this.signalWaitDeadlines.delete(key); + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); + return; + } + const previous = this.pendingSignalWaits.get(key); + if (previous) clearTimeout(previous.timer); + const timer = setTimeout(() => { + this.pendingSignalWaits.delete(key); + this.signalWaitDeadlines.delete(key); + if (this.isRegisteredChannel(channel)) { + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); + } + }, remainingMs); + this.pendingSignalWaits.set(key, { timer, channel, origArgs }); + } + return; + } - const fdsPtr = origArgs[0]; - const nfds = origArgs[1]; - if (fdsPtr === 0 || nfds === 0) return { pipeIndices: [], acceptIndices: [] }; + // Non-blocking FD check: if the FD has O_NONBLOCK set, return EAGAIN + // immediately instead of retrying. This is critical for programs like + // nginx that use non-blocking I/O and expect EAGAIN returned promptly. + // Also honor MSG_DONTWAIT on socket send/recv syscalls; unlike O_NONBLOCK, + // it lives only in the syscall arguments and should not enter the retry path. + if (syscallHasMsgDontwait(syscallNr, origArgs)) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EAGAIN, + ); + return; + } + + // Covers read/write, accept, accept4, and connect syscalls. + if ( + READ_LIKE_SYSCALLS.has(syscallNr) || + WRITE_LIKE_SYSCALLS.has(syscallNr) || + syscallNr === SYS_ACCEPT || + syscallNr === SYS_ACCEPT4 || + syscallNr === SYS_CONNECT + ) { + const fd = origArgs[0]; + const isFdNonblock = this.kernelInstance!.exports + .kernel_is_fd_nonblock as + ((pid: number, fd: number) => number) | undefined; + if (isFdNonblock) { + const nb = isFdNonblock(channel.pid, fd); + if (nb === 1) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EAGAIN, + ); + return; + } + } + } - // Find the channel for this pid to read process memory - const channel = this.activeChannels.find(c => c.pid === pid); - if (!channel) return { pipeIndices: [], acceptIndices: [] }; + // Non-blocking mqueue check: mq_timedsend/mq_timedreceive return EAGAIN + // from the kernel in both blocking and non-blocking modes (the kernel + // has no way to actually block). For non-blocking descriptors we must + // return EAGAIN to the caller; otherwise the default retry loop spins + // forever waiting for state that will never change (e.g., the final + // mq_receive in tests/sortix/os-test/basic/mqueue/mq_receive.c after mq_setattr sets + // O_NONBLOCK on an empty queue). + if (syscallNr === SYS_MQ_TIMEDSEND || syscallNr === SYS_MQ_TIMEDRECEIVE) { + const mqd = origArgs[0]; + const isFdNonblock = this.kernelInstance!.exports + .kernel_is_fd_nonblock as + ((pid: number, fd: number) => number) | undefined; + if (isFdNonblock && isFdNonblock(channel.pid, mqd) === 1) { + this.completeChannel( + channel, + syscallNr, + origArgs, + SYSCALL_ARGS[syscallNr], + -1, + EAGAIN, + ); + return; + } + } - const indices: number[] = []; - const acceptIndices: number[] = []; - const processMem = new DataView(channel.memory.buffer); - const POLLIN = 0x001; - // struct pollfd: fd(4) + events(2) + revents(2) = 8 bytes - for (let i = 0; i < nfds; i++) { - const fd = processMem.getInt32(fdsPtr + i * 8, true); - if (fd < 0) continue; - const events = processMem.getInt16(fdsPtr + i * 8 + 4, true); - if (getRecvPipe) { - const pipeIdx = getRecvPipe(pid, fd); - if (pipeIdx >= 0) { - indices.push(pipeIdx); + // Socket timeout check: if a read/write-like syscall blocks on a socket + // with SO_RCVTIMEO or SO_SNDTIMEO set, schedule a timer for ETIMEDOUT. + if ( + READ_LIKE_SYSCALLS.has(syscallNr) || + WRITE_LIKE_SYSCALLS.has(syscallNr) + ) { + const fd = origArgs[0]; + const getTimeout = this.kernelInstance!.exports + .kernel_get_socket_timeout_ms as + ((pid: number, fd: number, isRecv: number) => bigint) | undefined; + if (getTimeout && !this.socketTimeoutTimers.has(channel)) { + const isRecv = READ_LIKE_SYSCALLS.has(syscallNr) ? 1 : 0; + const timeoutMs = Number(getTimeout(channel.pid, fd, isRecv)); + if (timeoutMs > 0) { + const timer = setTimeout(() => { + if (this.socketTimeoutTimers.get(channel) !== timer) return; + this.socketTimeoutTimers.delete(channel); + // Remove from pending pipe readers if registered + this.removePendingPipeReader(channel); + if (this.isRegisteredChannel(channel)) { + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, ETIMEDOUT); + } + }, timeoutMs); + this.socketTimeoutTimers.set(channel, timer); } } - if (getAcceptWakeIdx && (events & POLLIN) !== 0) { - const acceptIdx = getAcceptWakeIdx(pid, fd); - if (acceptIdx >= 0) { - acceptIndices.push(acceptIdx); + } + + // Event-driven pipe/socket wakeup: if this is a read-like syscall on a + // pipe/socket fd, register the reader so a matching write can wake it + // immediately instead of polling via setImmediate. + if (READ_LIKE_SYSCALLS.has(syscallNr)) { + const fd = origArgs[0]; + const getFdPipeIdx = this.kernelInstance!.exports + .kernel_get_fd_pipe_idx as + ((pid: number, fd: number) => number) | undefined; + if (getFdPipeIdx) { + const pipeIdx = getFdPipeIdx(channel.pid, fd); + if (pipeIdx >= 0) { + let readers = this.pendingPipeReaders.get(pipeIdx); + if (!readers) { + readers = []; + this.pendingPipeReaders.set(pipeIdx, readers); + } + // Avoid duplicate registrations for the same channel + if (!readers.some(r => r.channel === channel)) { + readers.push({ channel, pid: channel.pid }); + } + if (PROFILING) { + const entry = this.profileData!.get(syscallNr); + if (entry) entry.retries++; + } + return; } } } - return { pipeIndices: indices, acceptIndices }; - } - - private resolveEpollReadinessIndices(pid: number): { pipeIndices: number[]; acceptIndices: number[] } { - const getRecvPipe = this.kernelInstance!.exports.kernel_get_socket_recv_pipe as - ((pid: number, fd: number) => number) | undefined; - const getAcceptWakeIdx = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (!getRecvPipe && !getAcceptWakeIdx) return { pipeIndices: [], acceptIndices: [] }; - const key = `${pid}:`; - const indices: number[] = []; - const acceptIndices: number[] = []; - const EPOLLIN = 0x001; - for (const [k, interests] of this.epollInterests) { - if (!k.startsWith(key)) continue; - for (const interest of interests) { - if (getRecvPipe) { - const pipeIdx = getRecvPipe(pid, interest.fd); - if (pipeIdx >= 0) { - indices.push(pipeIdx); + // Event-driven pipe/socket wakeup for writes: if a write-like syscall + // blocks because the pipe/socket send buffer is full, register the writer + // so a matching read (draining the pipe) can wake it immediately. + if (WRITE_LIKE_SYSCALLS.has(syscallNr)) { + const fd = origArgs[0]; + const getSendPipeIdx = this.kernelInstance!.exports + .kernel_get_fd_send_pipe_idx as + ((pid: number, fd: number) => number) | undefined; + if (getSendPipeIdx) { + const pipeIdx = getSendPipeIdx(channel.pid, fd); + if (pipeIdx >= 0) { + let writers = this.pendingPipeWriters.get(pipeIdx); + if (!writers) { + writers = []; + this.pendingPipeWriters.set(pipeIdx, writers); } - } - if (getAcceptWakeIdx && (interest.events & EPOLLIN) !== 0) { - const acceptIdx = getAcceptWakeIdx(pid, interest.fd); - if (acceptIdx >= 0) { - acceptIndices.push(acceptIdx); + if (!writers.some(w => w.channel === channel)) { + writers.push({ channel, pid: channel.pid }); + } + if (PROFILING) { + const entry = this.profileData!.get(syscallNr); + if (entry) entry.retries++; } + return; } } } - return { pipeIndices: indices, acceptIndices }; - } - private wakeBlockedAccept(acceptIdx: number): void { - const matches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => e.acceptIndices?.includes(acceptIdx), - ); - for (const [key, entry] of matches) { - if (this.pendingPollRetries.get(key) !== entry) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - } - this.pendingPollRetries.delete(key); - if (this.processes.has(entry.channel.pid)) { - this.retrySyscall(entry.channel); + // Event-driven wakeup for accept/accept4: register the listening socket's + // accept-readiness token so local connect/injected connection wakes the + // accept immediately instead of waiting for the fallback timer. + if (syscallNr === SYS_ACCEPT || syscallNr === SYS_ACCEPT4) { + const fd = origArgs[0]; + const getAcceptWakeIdx = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + if (getAcceptWakeIdx) { + const acceptIdx = getAcceptWakeIdx(channel.pid, fd); + if (acceptIdx >= 0) { + const retryFn = () => { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.retrySyscall(channel); + } + }; + const timer = setTimeout(retryFn, 10); + this.pendingPollRetries.set(channel, { + timer, + channel, + pipeIndices: [], + acceptIndices: [acceptIdx], + }); + if (PROFILING) { + const entry = this.profileData!.get(syscallNr); + if (entry) entry.retries++; + } + return; + } } } - } - private wakeBlockedPoll(pid: number, pipeIdx: number): void { - // retrySyscall runs handleSyscall synchronously, which can re-insert - // the same key via pendingPollRetries.set when the kernel returns - // EAGAIN. JS Map iterators are not snapshots — re-inserted entries - // appear at the new tail and the iterator yields them, livelocking - // wakeBlockedPoll-hit / poll / poll-register inside one tick. Mirror - // wakeAllBlockedRetries' snapshot-and-skip-if-replaced pattern. - const matches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => e.channel.pid === pid && e.pipeIndices.includes(pipeIdx), - ); - for (const [key, entry] of matches) { - if (this.pendingPollRetries.get(key) !== entry) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - } - this.pendingPollRetries.delete(key); - if (this.processes.has(pid)) { - this.retrySyscall(entry.channel); - } + if (PROFILING) { + const entry = this.profileData!.get(syscallNr); + if (entry) entry.retries++; } + + // Default: retry via setTimeout to avoid starving other processes. + // Register in pendingPollRetries so wakeAllBlockedRetries can cancel + // the timer and retry immediately when state changes. + const retryFn = () => { + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isAsyncChannelProcessActive(channel)) { + this.retrySyscall(channel); + } + }; + const timer = setTimeout(retryFn, 10); + this.pendingPollRetries.set(channel, { + timer, + channel, + pipeIndices: [], + isWriteRetry: WRITE_LIKE_SYSCALLS.has(syscallNr), + }); } /** - * Public wake helper for host-side pipe writes (TCP bridges, HTTP - * bridges, etc.). Call this AFTER directly writing into a pipe via - * `kernel_pipe_write` or `kernel_inject_connection`. - * - * In order: - * 1. Wake any process blocked in read/recv on this pipe - * (`pendingPipeReaders`). - * 2. Wake any process blocked in poll/ppoll/pselect6 whose - * `pipeIndices` includes this pipe (`pendingPollRetries`). - * Pass `pidFilter` to restrict the wake to a single owning - * pid — used by the Node TCP bridge when dispatching an - * inbound connection to a specific listener. - * 3. Schedule a broad wake (`scheduleWakeBlockedRetries`) for - * everything else. - * - * Without step 2, blocked pollers wait for the fallback timer in - * `handleBlockingRetry` to fire, which is the bug behind PR fixing - * the WordPress LAMP demo's slow install.php (see commit history). + * Retry a syscall by re-invoking handleSyscall with the original + * args still in the process channel. */ - public notifyPipeReadable(pipeIdx: number, pidFilter?: number): void { - // 1. Blocked readers - const readers = this.pendingPipeReaders.get(pipeIdx); - if (readers && readers.length > 0) { - this.pendingPipeReaders.delete(pipeIdx); - for (const reader of readers) { - if (this.processes.has(reader.pid)) { - this.retrySyscall(reader.channel); - } - } - } - // 2. Blocked pollers watching this pipe. Snapshot-and-skip-if-replaced: - // retrySyscall runs synchronously and a re-parking wait re-inserts the - // same channelOffset key, which a raw for..of over the live Map would - // revisit forever (see wakeBlockedPoll / sendSignalToProcess). - const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => - (pidFilter === undefined || e.channel.pid === pidFilter) && - e.pipeIndices.includes(pipeIdx), - ); - for (const [key, entry] of pollMatches) { - if (this.pendingPollRetries.get(key) !== entry) continue; - if (entry.timer !== null) clearTimeout(entry.timer); - this.pendingPollRetries.delete(key); - if (this.processes.has(entry.channel.pid)) { - this.retrySyscall(entry.channel); - } + private retrySyscall(channel: ChannelInfo): void { + // Deferred retry callbacks can outlive an exec image. Never consult or + // mutate the replacement generation through a discarded channel object. + if (!this.isRegisteredChannel(channel)) return; + if (this.deferChannelWhileStopped(channel)) return; + + // Check if the process was killed by a signal while blocking. + // This handles cases like sigsuspend + cross-process SIGABRT where + // deliver_pending_signals marks the target as Exited. + if (this.getProcessExitSignal(channel.pid) > 0) { + this.signalWaitDeadlines.delete( + `${channel.pid}:${channel.channelOffset}`, + ); + this.handleProcessTerminated(channel); + return; } - // 3. Broad wake for any other pending retries - this.scheduleWakeBlockedRetries(); + + // The process channel still has the original args (we never wrote a response). + // Just re-handle it. + this.handleSyscall(channel); } /** - * Public wake helper for host-side pipe reads (response pump in - * the TCP/HTTP bridges). Call this AFTER directly reading data - * from a pipe so any process blocked writing because the pipe was - * full can resume, plus a broad wake. + * Handle sleep syscalls where the kernel returns success immediately + * but we need to delay the channel response. + * Returns true if this is a sleep syscall that was handled. */ - public notifyPipeWritable(pipeIdx: number): void { - const writers = this.pendingPipeWriters.get(pipeIdx); - if (writers && writers.length > 0) { - this.pendingPipeWriters.delete(pipeIdx); - for (const writer of writers) { - if (this.processes.has(writer.pid)) { - this.retrySyscall(writer.channel); - } - } + private handleSleepDelay( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + ): boolean { + let delayMs = 0; + + if (syscallNr === SYS_NANOSLEEP && retVal >= 0) { + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const sec = kernelView.getUint32(CH_DATA, true); + const nsec = kernelView.getUint32(CH_DATA + 8, true); + delayMs = sec * 1000 + Math.floor(nsec / 1_000_000); + } else if (syscallNr === SYS_USLEEP && retVal >= 0) { + const usec = origArgs[0] >>> 0; + delayMs = Math.max(1, Math.floor(usec / 1000)); + } else if (syscallNr === SYS_CLOCK_NANOSLEEP && retVal >= 0) { + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const sec = kernelView.getUint32(CH_DATA, true); + const nsec = kernelView.getUint32(CH_DATA + 8, true); + delayMs = sec * 1000 + Math.floor(nsec / 1_000_000); } - this.scheduleWakeBlockedRetries(); - } - /** Cancel all pending poll retries for a given pid (used during cleanup) */ - private cleanupPendingPollRetries(pid: number): void { - for (const [key, entry] of this.pendingPollRetries) { - if (entry.channel.pid === pid) { - if (entry.timer) clearTimeout(entry.timer); - this.pendingPollRetries.delete(key); - } + if (delayMs > 0) { + const timer = setTimeout(() => { + const pending = this.pendingSleeps.get(channel); + if (pending?.timer !== timer || pending.channel !== channel) return; + this.pendingSleeps.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.completeSleepWithSignalCheck(channel, syscallNr, origArgs, retVal, errVal); + } + }, delayMs); + this.pendingSleeps.set(channel, { timer, channel, syscallNr, origArgs, retVal, errVal }); + return true; } + + return false; } - /** Cancel all pending select/pselect retries for a given pid. */ - private cleanupPendingSelectRetries(pid: number): void { - for (const [key, entry] of this.pendingSelectRetries) { - if (entry.channel.pid === pid) { - if (entry.timer !== null) { - clearTimeout(entry.timer); - clearImmediate(entry.timer); - } - this.pendingSelectRetries.delete(key); - } + /** + * Complete a sleep syscall, checking for pending signals first. + * POSIX: sleep interrupted by signal returns EINTR. + */ + private completeSleepWithSignalCheck( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + ): void { + // Check if a signal became pending during the sleep + this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return; + + // If a signal was dequeued, return EINTR instead of success + const processView = new DataView(channel.memory.buffer, channel.channelOffset); + const pendingSig = processView.getUint32(CH_SIG_SIGNUM, true); + if (pendingSig > 0) { + // POSIX: nanosleep/usleep interrupted by signal returns -1/EINTR + const EINTR = 4; + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR); + } else { + this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], retVal, errVal); } } + // ----------------------------------------------------------------------- + // Scatter/gather I/O handling (writev/readv/pwritev/preadv) + // + // These syscalls use struct iovec arrays with nested pointers: + // struct iovec { void *iov_base; size_t iov_len; } (8 bytes on wasm32) + // Both the iov array AND each iov_base buffer must be in kernel memory. + // ----------------------------------------------------------------------- + /** - * Drain kernel wakeup events and process targeted pipe/listener wakeups. - * Called after each syscall completion. The kernel pushes events from - * PipeBuffer operations and listener backlog changes. + * Handle writev/pwritev: copy iov array and all data buffers from + * process memory into kernel scratch, then call kernel_handle_channel. */ - private drainAndProcessWakeupEvents(): void { - const drainFn = this.kernelInstance!.exports.kernel_drain_wakeup_events as - ((outPtr: KernelPointer, outLen: number, maxEvents: number) => number) | undefined; - if (!drainFn) return; + /** + * Handle fcntl lock operations (F_GETLK, F_SETLK, F_SETLKW). + * Arg3 is a pointer to struct flock (32 bytes) which needs copy in/out. + */ + private handleFcntlLock(channel: ChannelInfo, origArgs: number[]): void { + const FLOCK_SIZE = 32; + const flockPtr = origArgs[2]; - const MAX_EVENTS = 256; - const BYTES_PER_EVENT = 5; - const bufSize = MAX_EVENTS * BYTES_PER_EVENT; + const processMem = new Uint8Array(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const dataStart = this.scratchOffset + CH_DATA; - const count = drainFn(this.toKernelPtr(this.scratchOffset), bufSize, MAX_EVENTS); - if (count === 0) return; + // Copy flock struct from process → kernel scratch + if (flockPtr !== 0) { + kernelMem.set(processMem.subarray(flockPtr, flockPtr + FLOCK_SIZE), dataStart); + } - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const WAKE_READABLE = 1; - const WAKE_WRITABLE = 2; - const WAKE_ACCEPT = 4; - let needBroadWake = false; + // Write syscall header to kernel scratch + kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(origArgs[0]), true); // fd + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(origArgs[1]), true); // cmd + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flockPtr !== 0 ? dataStart : 0), true); // flock_ptr in kernel memory + for (let i = 3; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(origArgs[i]), true); + } - for (let i = 0; i < count; i++) { - const off = this.scratchOffset + i * BYTES_PER_EVENT; - const wakeIdx = kernelMem[off] | (kernelMem[off + 1] << 8) | - (kernelMem[off + 2] << 16) | (kernelMem[off + 3] << 24); - const wakeType = kernelMem[off + 4]; + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } - if (wakeType & WAKE_READABLE) { - // Pipe became readable — wake pending readers on this pipe - const readers = this.pendingPipeReaders.get(wakeIdx); - if (readers && readers.length > 0) { - this.pendingPipeReaders.delete(wakeIdx); - for (const reader of readers) { - if (this.processes.has(reader.pid)) { - this.retrySyscall(reader.channel); - } - } - } - } + if (this.finishSignalTermination(channel)) return; - if (wakeType & WAKE_WRITABLE) { - // Pipe became writable — wake pending writers on this pipe - const writers = this.pendingPipeWriters.get(wakeIdx); - if (writers && writers.length > 0) { - this.pendingPipeWriters.delete(wakeIdx); - for (const writer of writers) { - if (this.processes.has(writer.pid)) { - this.retrySyscall(writer.channel); - } - } - } - } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - if (wakeType & WAKE_ACCEPT) { - this.wakeBlockedAccept(wakeIdx); - } + // Copy flock struct back from kernel → process (F_GETLK writes to it) + if (flockPtr !== 0 && retVal >= 0) { + const freshProcessMem = new Uint8Array(channel.memory.buffer); + freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FLOCK_SIZE), flockPtr); + } - needBroadWake = true; + const cmd = origArgs[1]; + if ( + retVal === -1 && + errVal === EAGAIN && + (cmd === F_SETLKW || cmd === F_SETLKW64 || cmd === F_OFD_SETLKW) + ) { + this.handleBlockingRetry(channel, SYS_FCNTL, origArgs); + return; } - // If any pipe state changed, also wake poll/select retries. - // - // If any of those retries is a signal-mask-swapping ppoll/pselect6, - // defer the wake a few ms. A pipe write from process X is often - // immediately followed by a cross-process signal (kill) from X — - // e.g. "write to pipe, then kill parent" — where the writer expects - // a blocked ppoll in the reader to observe BOTH events atomically. - // On a real kernel that works because X's two syscalls execute - // before the scheduler runs the reader. In our retry-based - // shared kernel, the pipe wakeup can fire a ppoll retry BEFORE - // X's follow-up kill is even sent by X's worker (Atomics.notify → - // uv_async round-trip takes 1–5ms). If the retry fires first, ppoll - // returns POLLIN and restores its sigmask; the late signal is then - // blocked and the handler never fires. See - // tests/sortix/os-test/signal/ppoll-block-sleep-write-raise. - // - // Deferring the broad wake a few ms gives X's follow-up syscalls - // time to land. Kill-triggered wakes (line ~2050) always use the - // immediate setImmediate path — by the time kill has been processed - // the signal is already queued, so there's no race. Pipe - // reader/writer wakes above run synchronously (not via this - // deferred path), so plain read/write throughput is unaffected. We - // only pay the delay when a pipe event happens to wake a ppoll or - // pselect6 caller. - if (needBroadWake) { - if (this.anyPendingRetryNeedsSignalSafeWake()) { - this.scheduleWakeBlockedRetriesDeferred(); + this.completeChannel(channel, SYS_FCNTL, origArgs, undefined, retVal, errVal); + } + + /** + * Handle pselect6: copy fd_sets (inout), decode timeout/sigmask from + * process memory, call kernel_handle_channel, copy fd_sets back. + * + * Layout in kernel scratch data area: + * [0..128] readfds (fd_set, 128 bytes) + * [128..256] writefds (fd_set, 128 bytes) + * [256..384] exceptfds (fd_set, 128 bytes) + * [384..392] mask (8 bytes: mask_lo + mask_hi) + */ + /** + * select(2) — args (nfds, readfds, writefds, exceptfds, *timeval). + * + * Differs from pselect6 only in the time struct: select takes + * `struct timeval { long sec; long usec; }` and has no sigmask. musl + * routes here on wasm64 because `__NR_pselect6_time64` isn't defined for + * that arch (unlike wasm32, which aliases it to __NR_pselect6 and lands + * on pselect6 instead). + * + * We decode timeval → ms, drive the kernel with sys_select directly, and + * mirror handlePselect6's EAGAIN/timeout bookkeeping. The hot path in our + * 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 { + if (this.deferChannelWhileStopped(channel)) return; + const FD_SET_SIZE = 128; + const nfds = origArgs[0]; + const readPtr = origArgs[1]; + const writePtr = origArgs[2]; + const exceptPtr = origArgs[3]; + const tvPtr = origArgs[4]; + + let timeoutMs = -1; // -1 = infinite (NULL timeval) + if (tvPtr !== 0) { + const ptrWidth = this.getPtrWidth(channel.pid); + const pv = new DataView(channel.memory.buffer, tvPtr); + let sec: number, usec: number; + if (ptrWidth === 8) { + sec = Number(pv.getBigInt64(0, true)); + usec = Number(pv.getBigInt64(8, true)); } else { - this.scheduleWakeBlockedRetries(); + sec = pv.getInt32(0, true); + usec = pv.getInt32(4, true); } + timeoutMs = sec * 1000 + Math.floor(usec / 1000); + if (timeoutMs < 0) timeoutMs = 0; } - } + const finalCheck = channel.readinessFinalCheck === true; + channel.readinessFinalCheck = false; + const kernelTimeoutMs = finalCheck ? 0 : timeoutMs; + const deadline = this.getReadinessDeadline(channel, timeoutMs); - private anyPendingRetryNeedsSignalSafeWake(): boolean { - for (const entry of this.pendingPollRetries.values()) { - if (entry.needsSignalSafeWake) return true; - } - for (const entry of this.pendingSelectRetries.values()) { - if (entry.needsSignalSafeWake) return true; + // Pure-sleep fast path: select(0, NULL, NULL, NULL, &tv) is `my_sleep`. + // The kernel can't tell us anything new — there are no fds to poll — + // so we just wait the timeout and return 0. Tracked in + // pendingSelectRetries so a cross-process kill can break us out early + // (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; + } + const finite = timeoutMs > 0; + const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : -1; + const timer = finite + ? setTimeout(() => { + if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); + } + }, remainingMs) + : (null as any); + this.pendingSelectRetries.set(channel, { + timer, + channel, + origArgs, + deadline, + needsSignalSafeWake: false, + syscallNr: SYS_SELECT, + }); + return; } - return false; - } - - /** Same as scheduleWakeBlockedRetries but delays by a few ms to allow - * follow-up cross-process syscalls from the event source to land. */ - private scheduleWakeBlockedRetriesDeferred(): void { - if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return; - this.postponeSignalSafePollRetries(SIGNAL_SAFE_POLL_WAKE_DELAY_MS); - if (this.wakeScheduled) return; - this.wakeScheduled = true; - setTimeout(() => { - this.wakeScheduled = false; - this.wakeAllBlockedRetries(); - }, SIGNAL_SAFE_POLL_WAKE_DELAY_MS); - } - private postponeSignalSafePollRetries(delayMs: number): void { - const now = Date.now(); - for (const [key, entry] of this.pendingPollRetries) { - if (!entry.needsSignalSafeWake) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - } + // General case: dispatch to the kernel's sys_select with timeout_ms in + // arg5. fd_sets are copied via the standard pre-existing scratch flow + // (kernel_select reads readfds_ptr/writefds_ptr/exceptfds_ptr into + // process memory directly, so we copy them in just like handlePselect6). + const processMem = new Uint8Array(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const dataStart = this.scratchOffset + CH_DATA; - const remainingMs = entry.deadline && entry.deadline > 0 - ? Math.max(1, entry.deadline - now) - : delayMs; - const retryMs = Math.max(1, Math.min(delayMs, remainingMs)); - entry.timer = setTimeout(() => { - this.pendingPollRetries.delete(key); - if (this.processes.has(entry.channel.pid)) { - this.retrySyscall(entry.channel); - } - }, retryMs); + if (readPtr !== 0) { + kernelMem.set(processMem.subarray(readPtr, readPtr + FD_SET_SIZE), dataStart); + } else { + kernelMem.fill(0, dataStart, dataStart + FD_SET_SIZE); + } + if (writePtr !== 0) { + kernelMem.set(processMem.subarray(writePtr, writePtr + FD_SET_SIZE), dataStart + FD_SET_SIZE); + } else { + kernelMem.fill(0, dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE); + } + if (exceptPtr !== 0) { + kernelMem.set(processMem.subarray(exceptPtr, exceptPtr + FD_SET_SIZE), dataStart + 2 * FD_SET_SIZE); + } else { + kernelMem.fill(0, dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE); } - } - - /** - * Schedule a microtask to wake all blocked poll/pselect6 retries. - * Coalesced via wakeScheduled flag — multiple calls within the same - * microtask batch result in only one wake cycle. This catches cross-process - * pipe writes, socket connections, and other state changes that unblock - * another process's pending poll/select. - */ - private scheduleWakeBlockedRetries(): void { - if (this.wakeScheduled) return; - if (this.pendingPollRetries.size === 0 && this.pendingSelectRetries.size === 0 && this.pendingPipeReaders.size === 0 && this.pendingPipeWriters.size === 0) return; - this.wakeScheduled = true; - // Use setImmediate (not queueMicrotask) so that timer callbacks - // (setTimeout/setInterval) can interleave. In browsers, microtask - // chains from queueMicrotask starve all macrotasks, breaking progress - // updates and timeouts. setImmediate goes through the polyfill which - // yields to the timer queue periodically. - setImmediate(() => { - this.wakeScheduled = false; - this.wakeAllBlockedRetries(); - }); - } - /** - * Wake all blocked poll/pselect6 retries by cancelling their setImmediate - * timers and immediately re-executing the syscalls. - */ - private wakeAllBlockedRetries(): void { - // Snapshot and clear — retries may re-add themselves if still not ready - const pollEntries = Array.from(this.pendingPollRetries.entries()); - const selectEntries = Array.from(this.pendingSelectRetries.entries()); - this.pendingPollRetries.clear(); - this.pendingSelectRetries.clear(); + kernelView.setUint32(CH_SYSCALL, SYS_SELECT, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(nfds), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(kernelTimeoutMs), true); - for (const [_key, entry] of pollEntries) { - if (!this.processes.has(entry.channel.pid)) continue; - if (entry.timer !== null) { - clearTimeout(entry.timer); - } - this.retrySyscall(entry.channel); + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; } - for (const [, entry] of selectEntries) { - if (!this.processes.has(entry.channel.pid)) continue; - // Cancel both setTimeout and setImmediate handles (one will be a no-op) - clearTimeout(entry.timer); - clearImmediate(entry.timer); - // Re-dispatch to the right handler — SYS_SELECT and SYS_PSELECT6 have - // different time-struct shapes (timeval vs timespec). - if (entry.syscallNr === SYS_SELECT) { - this.handleSelect(entry.channel, entry.origArgs); - } else { - this.handlePselect6(entry.channel, entry.origArgs); - } - } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - // Also wake all pending pipe readers — a cross-process write may have - // made data available on pipes that readers are waiting on. - if (this.pendingPipeReaders.size > 0) { - const pipeEntries = Array.from(this.pendingPipeReaders.entries()); - this.pendingPipeReaders.clear(); - for (const [, readers] of pipeEntries) { - for (const reader of readers) { - if (this.processes.has(reader.pid)) { - this.retrySyscall(reader.channel); - } - } + // Copy fd_sets back from kernel → process on success + if (retVal >= 0) { + const freshProcessMem = new Uint8Array(channel.memory.buffer); + if (readPtr !== 0) { + freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FD_SET_SIZE), readPtr); + } + if (writePtr !== 0) { + freshProcessMem.set( + kernelMem.subarray(dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE), + writePtr, + ); + } + if (exceptPtr !== 0) { + freshProcessMem.set( + kernelMem.subarray(dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE), + exceptPtr, + ); } } - // Also wake all pending pipe writers — a cross-process read may have - // drained pipe buffer space that writers are waiting on. - if (this.pendingPipeWriters.size > 0) { - const writerEntries = Array.from(this.pendingPipeWriters.entries()); - this.pendingPipeWriters.clear(); - for (const [, writers] of writerEntries) { - for (const writer of writers) { - if (this.processes.has(writer.pid)) { - this.retrySyscall(writer.channel); - } - } + if (this.completeSelectSignalOutcome( + channel, + SYS_SELECT, + origArgs, + retVal === -1 && errVal === EAGAIN, + )) return; + + // EAGAIN retry for blocking select. Mirrors handlePselect6. + if (retVal === -1 && errVal === EAGAIN) { + if (timeoutMs === 0) { + this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); + return; + } + if (deadline > 0 && Date.now() >= deadline) { + channel.readinessFinalCheck = true; + this.handleSelect(channel, origArgs); + return; } + const retryFn = () => { + const pending = this.pendingSelectRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; + this.handleSelect(channel, origArgs); + }; + const finite = timeoutMs > 0; + const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : 50; + const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); + this.pendingSelectRetries.set(channel, { + timer, + channel, + origArgs, + deadline, + needsSignalSafeWake: false, + syscallNr: SYS_SELECT, + }); + return; } + + this.completeChannel( + channel, + SYS_SELECT, + origArgs, + undefined, + retVal, + errVal, + ); } - /** - * Remove a process's entries from pendingPipeReaders. - * Called during process cleanup. - */ - private cleanupPendingPipeReaders(pid: number): void { - for (const [pipeIdx, readers] of this.pendingPipeReaders) { - const filtered = readers.filter(r => r.pid !== pid); - if (filtered.length === 0) { - this.pendingPipeReaders.delete(pipeIdx); - } else { - this.pendingPipeReaders.set(pipeIdx, filtered); - } + private handlePselect6(channel: ChannelInfo, origArgs: number[]): void { + if (this.deferChannelWhileStopped(channel)) return; + const FD_SET_SIZE = 128; + const processMem = new Uint8Array(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const kernelView = new DataView( + this.kernelMemory!.buffer, + this.scratchOffset, + ); + const dataStart = this.scratchOffset + CH_DATA; + + const nfds = origArgs[0]; + const readPtr = origArgs[1]; + const writePtr = origArgs[2]; + const exceptPtr = origArgs[3]; + const tsPtr = origArgs[4]; + const maskDataPtr = origArgs[5]; // pointer to {sigset_t *mask, size_t size} + + // Copy fd_sets from process → kernel scratch + if (readPtr !== 0) { + kernelMem.set(processMem.subarray(readPtr, readPtr + FD_SET_SIZE), dataStart); + } else { + kernelMem.fill(0, dataStart, dataStart + FD_SET_SIZE); + } + if (writePtr !== 0) { + kernelMem.set(processMem.subarray(writePtr, writePtr + FD_SET_SIZE), dataStart + FD_SET_SIZE); + } else { + kernelMem.fill(0, dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE); } - } - - private cleanupPendingPipeWriters(pid: number): void { - for (const [pipeIdx, writers] of this.pendingPipeWriters) { - const filtered = writers.filter(w => w.pid !== pid); - if (filtered.length === 0) { - this.pendingPipeWriters.delete(pipeIdx); - } else { - this.pendingPipeWriters.set(pipeIdx, filtered); - } + if (exceptPtr !== 0) { + kernelMem.set(processMem.subarray(exceptPtr, exceptPtr + FD_SET_SIZE), dataStart + 2 * FD_SET_SIZE); + } else { + kernelMem.fill(0, dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE); } - } - /** - * Cancel a pending socket timeout timer for a channel. - */ - private clearSocketTimeout(channel: ChannelInfo): void { - const timer = this.socketTimeoutTimers.get(channel); - if (timer !== undefined) { - clearTimeout(timer); - this.socketTimeoutTimers.delete(channel); + // Decode timeout: timespec {i64 sec, i64 nsec} → ms + let timeoutMs = -1; + if (tsPtr !== 0) { + const pv = new DataView(channel.memory.buffer, tsPtr); + const sec = Number(pv.getBigInt64(0, true)); + const nsec = Number(pv.getBigInt64(8, true)); + timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); } - } + const finalCheck = channel.readinessFinalCheck === true; + channel.readinessFinalCheck = false; + const kernelTimeoutMs = finalCheck ? 0 : timeoutMs; + const deadline = this.getReadinessDeadline(channel, timeoutMs); - /** - * Remove a channel from pending pipe readers (all pipes). - * Called when a socket timeout fires to clean up the reader registration. - */ - private removePendingPipeReader(channel: ChannelInfo): void { - for (const [pipeIdx, readers] of this.pendingPipeReaders) { - const filtered = readers.filter(r => r.channel !== channel); - if (filtered.length === 0) { - this.pendingPipeReaders.delete(pipeIdx); - } else if (filtered.length !== readers.length) { - this.pendingPipeReaders.set(pipeIdx, filtered); + // Decode sigmask: pselect6 arg6 → pointer to {sigset_t *mask, size_t size} + // On wasm32: {u32 mask_ptr, u32 size} = 8 bytes + // On wasm64: {u64 mask_ptr, u64 size} = 16 bytes + // + // POSIX pselect6 semantics: arg6 points at `{const sigset_t *ss, size_t + // ss_len}`. If `ss == NULL`, the syscall must NOT swap the signal mask + // (callers like glibc's `select(2)` wrapper pass a non-NULL outer struct + // with `ss=NULL` to use the unified syscall path without requesting a + // mask swap). We mirror that here by treating "inner mask NULL" the same + // as "outer struct NULL": don't pass a mask-pointer to the kernel, so + // sys_pselect6 leaves `mask=None` and skips the temp-mask path. Without + // this, mariadbd's `select()` from main blew its sigmask away to 0 every + // call, letting the next kill(getpid, SIGTERM) fire the main-thread + // handler before the dedicated `signal_hand` thread could `sigwait` it + // — `wait_for_signal_thread_to_end` then spun forever. + const maskOffset = dataStart + 3 * FD_SET_SIZE; + let kernelMaskPtr = 0; // 0 = no mask swap + if (maskDataPtr !== 0) { + const pw = this.getPtrWidth(channel.pid); + const mdv = new DataView(channel.memory.buffer, maskDataPtr); + const maskPtr = pw === 8 + ? Number(mdv.getBigUint64(0, true)) + : mdv.getUint32(0, true); + if (maskPtr !== 0) { + kernelMem.set(processMem.subarray(maskPtr, maskPtr + 8), maskOffset); + kernelMaskPtr = maskOffset; } } - } - /** - * Remove a channel from pending pipe writers (all pipes). - */ - private removePendingPipeWriter(channel: ChannelInfo): void { - for (const [pipeIdx, writers] of this.pendingPipeWriters) { - const filtered = writers.filter(w => w.channel !== channel); - if (filtered.length === 0) { - this.pendingPipeWriters.delete(pipeIdx); - } else if (filtered.length !== writers.length) { - this.pendingPipeWriters.set(pipeIdx, filtered); - } - } - } + // Write args: (nfds, readfds_kernel_ptr, writefds_kernel_ptr, + // exceptfds_kernel_ptr, timeout_ms, mask_kernel_ptr) + kernelView.setUint32(CH_SYSCALL, SYS_PSELECT6, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(nfds), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(kernelTimeoutMs), true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(kernelMaskPtr), true); - /** - * SYS_THREAD_CANCEL — wake a thread that is blocked in a cancellation-point - * syscall so its glue (__syscall_cp) can observe the pending cancel flag - * and run pthread_exit(PTHREAD_CANCELED). - * - * The guest pthread_cancel() overlay has already atomically set - * target->cancel = 1 in shared memory before calling this syscall — see - * libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c for the full flow. - * - * This handler's sole job is to force the target out of its Atomics.wait32 - * on CH_STATUS (if blocked). Strategy depends on what the target is - * waiting on: - * - * - futex wait: fire Atomics.notify on the futex address. handleFutex's - * waitAsync Promise resolves, writes (0, 0) to the channel, target - * wakes. Return-value 0 is benign — the post-syscall __testcancel() - * in glue picks up self->cancel and exits before the caller re-checks - * its predicate. - * - pipe read/write blocked on pendingPipeReaders/Writers: remove the - * registration and complete the channel with -EINTR. - * - poll/select scheduled with a retry timer: clear the timer and - * complete with -EINTR. - * - otherwise (not blocked, or already completed): no-op. The target - * will observe self->cancel on its next cancel-point entry. - * - * The caller's own syscall always succeeds with 0. - */ - private handleThreadCancel(channel: ChannelInfo, origArgs: number[]): void { - const targetTid = origArgs[0]; - const registration = this.processes.get(channel.pid); + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } - // Always complete the caller's syscall first so pthread_cancel returns. - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - if (!registration) return; + // pselect6 debug logging disabled - // Resolve target channel: main thread has tid == pid; other threads are - // tracked in channelTids by their clone-assigned tid. - let target: ChannelInfo | undefined; - for (const ch of registration.channels) { - const mappedTid = this.channelTids.get(`${channel.pid}:${ch.channelOffset}`); - const effectiveTid = mappedTid !== undefined ? mappedTid : channel.pid; - if (effectiveTid === targetTid) { - target = ch; - break; + // Copy fd_sets back from kernel → process + if (retVal >= 0) { + const freshProcessMem = new Uint8Array(channel.memory.buffer); + if (readPtr !== 0) { + freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FD_SET_SIZE), readPtr); + } + if (writePtr !== 0) { + freshProcessMem.set( + kernelMem.subarray(dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE), + writePtr, + ); + } + if (exceptPtr !== 0) { + freshProcessMem.set( + kernelMem.subarray(dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE), + exceptPtr, + ); } } - if (!target) return; - // Arm the cancel flag: blocking syscall entry points check this and - // short-circuit with -EINTR. Also consulted by the generic syscall - // dispatcher in _handleSyscallInner for cases where the target had - // already pushed a syscall onto the channel but the host hadn't yet - // started the blocking wait when the cancel arrived. - this.pendingCancels.add(target.channelOffset); + if (this.completeSelectSignalOutcome( + channel, + SYS_PSELECT6, + origArgs, + retVal === -1 && errVal === EAGAIN, + )) return; - // If the target has already parked in a tracked blocking wait, wake - // it so its natural completion path runs and the guest sees the - // cancel in __syscall_cp_check. Doing the wake via the same mechanism - // the wait uses (Atomics.notify on the futex addr, cancelling the - // retry timer, etc.) avoids racing against the handler's own - // completion path — we never write the channel directly here. + // Handle EAGAIN retry for blocking select + if (retVal === -1 && errVal === EAGAIN) { + if (timeoutMs === 0) { + this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); + return; + } + if (deadline > 0 && Date.now() >= deadline) { + channel.readinessFinalCheck = true; + this.handlePselect6(channel, origArgs); + return; + } - // 1) Futex wait — Atomics.notify wakes the in-flight waitAsync, which - // calls complete() and completeChannelRaw naturally. - const futexEntry = this.pendingFutexWaits.get(target.channelOffset); - if (futexEntry) { - const tgtMemView = new Int32Array(target.memory.buffer); - Atomics.notify(tgtMemView, futexEntry.futexIndex, 1); - return; - } + // pselect6 with a non-null sigmask pointer has the same late-signal + // race as ppoll. See scheduleWakeBlockedRetriesDeferred. + const needsSignalSafeWake = kernelMaskPtr !== 0; - // 2) Poll/ppoll retry timer — cancelling the timer and kicking a - // retry lets handleBlockingRetry see pendingCancels and complete - // with -EINTR the same way an unblocked syscall entry would. - const pollEntry = this.pendingPollRetries.get(target.channelOffset); - if (pollEntry) { - if (pollEntry.timer !== null) clearTimeout(pollEntry.timer); - this.pendingPollRetries.delete(target.channelOffset); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); - return; - } + // nfds=0: pure sleep/sigsuspend-like behavior. + // With finite timeout: sleep for that duration. + // With infinite timeout: block until signal (wakeAllBlockedRetries). + if (nfds === 0) { + if (timeoutMs > 0) { + const remainingMs = Math.max(deadline - Date.now(), 1); + const timer = setTimeout(() => { + if (this.pendingSelectRetries.get(channel)?.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + channel.readinessFinalCheck = true; + this.handlePselect6(channel, origArgs); + } + }, remainingMs); + this.pendingSelectRetries.set(channel, { + timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, + }); + } else { + // Infinite timeout with nfds=0: wait for signal delivery. + // No timer — wakeAllBlockedRetries will trigger the retry. + this.pendingSelectRetries.set(channel, { + timer: null as any, channel, origArgs, deadline: -1, + needsSignalSafeWake, syscallNr: SYS_PSELECT6, + }); + } + return; + } - // 3) Select/pselect retry timer. - const selEntry = this.pendingSelectRetries.get(target.channelOffset); - if (selEntry && selEntry.channel === target) { - clearTimeout(selEntry.timer); - clearImmediate(selEntry.timer); - this.pendingSelectRetries.delete(target.channelOffset); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); + // For finite timeout with actual fds, track the deadline + const retryFn = () => { + const pending = this.pendingSelectRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingSelectRetries.delete(channel); + if (!this.isRegisteredChannel(channel)) return; + this.handlePselect6(channel, origArgs); + }; + const remainingMs = deadline > 0 ? Math.max(deadline - Date.now(), 1) : 50; + const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); + this.pendingSelectRetries.set(channel, { + timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, + }); return; } - // 4) Pipe/socket reader/writer registration — unregister and wake. - let wokePipe = false; - for (const [pipeIdx, readers] of this.pendingPipeReaders) { - const filtered = readers.filter(r => r.channel !== target); - if (filtered.length !== readers.length) { - if (filtered.length === 0) this.pendingPipeReaders.delete(pipeIdx); - else this.pendingPipeReaders.set(pipeIdx, filtered); - wokePipe = true; - } + this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, retVal, errVal); + } + + // ---- epoll host-side implementation ---- + // kernel_handle_channel crashes in Chrome for epoll_pwait (suspected V8 + // shared-memory Wasm bug). We handle all epoll syscalls on the host: + // epoll_create1/create → still call kernel (works fine), mirror result + // epoll_ctl → still call kernel (works fine), mirror interest list + // epoll_pwait → convert to poll entirely on host, no kernel_handle_channel + + /** + * Handle epoll_create1 / epoll_create: let the kernel create the fd, + * then initialise an empty interest list on the host side. + */ + private handleEpollCreate(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const flags = origArgs[0]; + + // For SYS_EPOLL_CREATE, kernel expects flags=0 (size arg ignored) + const actualFlags = syscallNr === SYS_EPOLL_CREATE ? 0 : flags; + + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(actualFlags), true); + for (let i = 1; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - for (const [pipeIdx, writers] of this.pendingPipeWriters) { - const filtered = writers.filter(w => w.channel !== target); - if (filtered.length !== writers.length) { - if (filtered.length === 0) this.pendingPipeWriters.delete(pipeIdx); - else this.pendingPipeWriters.set(pipeIdx, filtered); - wokePipe = true; - } + + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; } - if (wokePipe) { - this.clearSocketTimeout(target); - this.completeChannelRaw(target, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(target); - return; + + if (this.finishSignalTermination(channel)) return; + + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + + // If successful, initialise the host-side interest mirror + if (retVal >= 0) { + const key = `${channel.pid}:${retVal}`; + this.epollInterests.set(key, []); } - // 5) No tracked blocking state — the target either hasn't reached the - // blocking entry yet, or its handler is synchronous and will pick - // up pendingCancels the next time it enters a blocking operation. - // Do NOT write the channel here: the in-flight handleSyscall owns - // it and would race with our completeChannelRaw. + this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); } /** - * Dump syscall profiling data to stderr. Call from your serve script: - * process.on('SIGINT', () => { kernelWorker.dumpProfile(); process.exit(); }); - * - * Only produces output when WASM_POSIX_PROFILE=1 env var is set. + * Handle epoll_ctl: let the kernel modify its interest list, then mirror + * the change on the host side. */ - dumpProfile(): void { - if (!this.profileData) { - console.error('[profile] Profiling not enabled. Set WASM_POSIX_PROFILE=1'); - return; + private handleEpollCtl(channel: ChannelInfo, origArgs: number[]): void { + const epfd = origArgs[0]; + const op = origArgs[1]; + const fd = origArgs[2]; + const eventPtr = origArgs[3]; // pointer in process memory + + // Read epoll_event from process memory: { events: u32, data: u64 } = 12 bytes + let events = 0; + let data = 0n; + if (eventPtr !== 0) { + const pv = new DataView(channel.memory.buffer, eventPtr); + events = pv.getUint32(0, true); + data = pv.getBigUint64(4, true); } - const entries = Array.from(this.profileData.entries()) - .sort((a, b) => b[1].totalTimeMs - a[1].totalTimeMs); + // Call kernel — copy event struct to scratch + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const kernelMem = this.getKernelMem(); + const dataStart = this.scratchOffset + CH_DATA; - let totalCalls = 0; - let totalTime = 0; - let totalRetries = 0; + // Copy 12-byte epoll_event to kernel scratch + if (eventPtr !== 0) { + const processMem = new Uint8Array(channel.memory.buffer); + kernelMem.set(processMem.subarray(eventPtr, eventPtr + 12), dataStart); + } - console.error('\n=== Syscall Profile ==='); - console.error(`${'Syscall'.padEnd(8)} ${'Count'.padStart(10)} ${'Time(ms)'.padStart(12)} ${'Avg(ms)'.padStart(10)} ${'Retries'.padStart(10)}`); - console.error('-'.repeat(52)); + kernelView.setUint32(CH_SYSCALL, SYS_EPOLL_CTL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(epfd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(op), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(eventPtr !== 0 ? dataStart : 0), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - for (const [nr, data] of entries) { - totalCalls += data.count; - totalTime += data.totalTimeMs; - totalRetries += data.retries; - console.error( - `${String(nr).padEnd(8)} ${String(data.count).padStart(10)} ${data.totalTimeMs.toFixed(2).padStart(12)} ${(data.totalTimeMs / data.count).toFixed(3).padStart(10)} ${String(data.retries).padStart(10)}` - ); + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; } - console.error('-'.repeat(52)); - console.error( - `${'TOTAL'.padEnd(8)} ${String(totalCalls).padStart(10)} ${totalTime.toFixed(2).padStart(12)} ${(totalTime / (totalCalls || 1)).toFixed(3).padStart(10)} ${String(totalRetries).padStart(10)}` - ); - console.error(`Pending pipe readers: ${this.pendingPipeReaders.size}, writers: ${this.pendingPipeWriters.size}`); - console.error('=== End Profile ===\n'); - } + if (this.finishSignalTermination(channel)) return; - private flushTcpSendPipes(pid: number): void { - const conns = this.tcpConnections.get(pid); - if (!conns || conns.length === 0) return; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - const pipeRead = this.kernelInstance!.exports.kernel_pipe_read as - (pid: number, pipeIdx: number, bufPtr: KernelPointer, bufLen: number) => number; - const mem = this.getKernelMem(); + // Mirror the change on the host side if the kernel succeeded + if (retVal === 0) { + const EPOLL_CTL_ADD = 1; + const EPOLL_CTL_DEL = 2; + const EPOLL_CTL_MOD = 3; - // Injected-connection pipes live in the global pipe table; pid=0 - // tells kernel_pipe_read to use it directly. See kernel_inject_connection. - for (const conn of conns) { - // Drain all available data from the send pipe (not just one chunk) - for (;;) { - const readN = pipeRead(0, conn.sendPipeIdx, this.toKernelPtr(conn.scratchOffset), 65536); - if (readN <= 0) break; - const outData = Buffer.from(mem.slice(conn.scratchOffset, conn.scratchOffset + readN)); - if (!conn.clientSocket.destroyed) { - conn.clientSocket.write(outData); + const key = `${channel.pid}:${epfd}`; + let interests = this.epollInterests.get(key); + if (!interests) { + interests = []; + this.epollInterests.set(key, interests); + } + + if (op === EPOLL_CTL_ADD) { + interests.push({ fd, events, data }); + } else if (op === EPOLL_CTL_DEL) { + const idx = interests.findIndex(e => e.fd === fd); + if (idx >= 0) interests.splice(idx, 1); + } else if (op === EPOLL_CTL_MOD) { + const entry = interests.find(e => e.fd === fd); + if (entry) { + entry.events = events; + entry.data = data; } } - // Schedule pump to detect pipe closure (PHP closing the socket) - conn.schedulePump(); } + + this.completeChannel(channel, SYS_EPOLL_CTL, origArgs, undefined, retVal, errVal); } - private handleBlockingRetry( + /** Complete or reap an epoll wait when its kernel signal boundary fired. */ + private completeEpollSignalOutcome(channel: ChannelInfo): boolean { + const deliveredSignal = this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return true; + if (deliveredSignal > 0) { + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(channel); + return true; + } + return false; + } + + /** + * Handle epoll_pwait / epoll_wait entirely on the host side. + * Converts the epoll interest list to a poll syscall, calls + * kernel_handle_channel with SYS_POLL, then maps results back + * to epoll_event format and writes to process memory. + */ + private handleEpollPwait( channel: ChannelInfo, syscallNr: number, origArgs: number[], ): void { - if (!this.processes.has(channel.pid)) return; - - // Futex wait: use Atomics.waitAsync on the target address in process memory - if (syscallNr === SYS_FUTEX) { - const futexOp = origArgs[1] & 0x7f; // mask out FUTEX_PRIVATE_FLAG - if (futexOp === 0) { // FUTEX_WAIT - const addr = origArgs[0]; // address in process memory - const expectedVal = origArgs[2]; - const i32View = new Int32Array(channel.memory.buffer); - const index = addr >>> 2; // convert byte offset to i32 index + if (this.deferChannelWhileStopped(channel)) return; + const epfd = origArgs[0]; + const eventsPtr = origArgs[1]; // output pointer in process memory + const maxevents = origArgs[2]; + const timeoutMs = origArgs[3]; + const deadline = this.getReadinessDeadline(channel, timeoutMs); + // origArgs[4] = sigmask ptr (process-space), origArgs[5] = sigset size - // Check if value already changed - const currentVal = Atomics.load(i32View, index); - if (currentVal !== expectedVal) { - // Value changed, retry syscall immediately — kernel should succeed - this.retrySyscall(channel); - return; - } + if (maxevents <= 0) { + this.completeChannelRaw(channel, -22, 22); // -EINVAL + this.relistenChannel(channel); + return; + } - // Wait for value to change - const waitResult = Atomics.waitAsync(i32View, index, expectedVal); - if (waitResult.async) { - waitResult.value.then(() => { - if (this.processes.has(channel.pid)) { - this.retrySyscall(channel); - } - }); - } else { - // Already changed — use setImmediate (not queueMicrotask) to avoid - // microtask chains that starve the browser event loop. - setImmediate(() => this.retrySyscall(channel)); - } - return; - } + const key = `${channel.pid}:${epfd}`; + const interests = this.epollInterests.get(key); + if (!interests) { + this.completeChannelRaw(channel, -9, 9); // -EBADF + this.relistenChannel(channel); + return; } - // Poll with timeout: the kernel did a non-blocking check and returned EAGAIN. - // We retry after a short delay. If poll has timeout=0 (EAGAIN means no events), - // we should return 0 immediately instead of retrying. - if (syscallNr === SYS_POLL || syscallNr === SYS_PPOLL) { - let timeoutMs = -1; - // PPOLL with a non-null sigmask pointer swaps the signal mask for the - // duration of the wait. Broad wakes from cross-process pipe writes - // need a short grace period for such callers so follow-up signals - // from the writer land before ppoll returns with fds ready. - const needsSignalSafeWake = syscallNr === SYS_PPOLL && origArgs[3] !== 0; - if (syscallNr === SYS_POLL) { - timeoutMs = origArgs[2]; // timeout in ms - } else { - const tsPtr = origArgs[2]; - if (tsPtr !== 0) { - const pv = new DataView(channel.memory.buffer, tsPtr); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); - } - } + if (interests.length === 0) { + // No poll call follows for an empty interest set, so explicitly service + // the signal boundary before parking or returning a timeout result. + if (this.completeEpollSignalOutcome(channel)) return; + + // No interests registered — return 0 immediately for timeout=0, + // or block (EAGAIN) for non-zero timeout. if (timeoutMs === 0) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); + this.completeChannelRaw(channel, 0, 0); + this.relistenChannel(channel); return; } - - // Resolve which pipe/listener readiness tokens the polled fds map to. - const { pipeIndices, acceptIndices } = - this.resolvePollReadinessIndices(channel.pid, origArgs); - - // For finite timeout, track the deadline so we return 0 (timeout) when it - // expires instead of retrying forever. The nfds=0 case (pure sleep) is - // optimized to skip retries entirely — just wait for the deadline. - const nfds = origArgs[1]; // poll(fds, nfds, ...) / ppoll(fds, nfds, ...) - if (timeoutMs > 0 && nfds === 0) { - // Pure sleep: no fds to poll, just wait for timeout - const timer = setTimeout(() => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); - } - }, timeoutMs); - this.pendingPollRetries.set(channel.channelOffset, { - timer, - channel, - pipeIndices, - acceptIndices, - needsSignalSafeWake, - deadline: Date.now() + timeoutMs, - }); + if (deadline > 0 && Date.now() >= deadline) { + this.completeChannelRaw(channel, 0, 0); + this.relistenChannel(channel); return; } - - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : -1; + // For non-zero timeout with no interests, retry with delay to avoid starvation const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; - // Check deadline for finite timeout - if (deadline > 0 && Date.now() >= deadline) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], 0, 0); - return; + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.handleEpollPwait(channel, syscallNr, origArgs); } - this.retrySyscall(channel); }; - // With pipe/listener readiness tokens, rely on targeted wakeups for - // instant retry. The timer is only a safety net. - // The intended wakeup path is event-driven: - // drainAndProcessWakeupEvents → scheduleWakeBlockedRetries - // (setImmediate) retries the poll when any of its watched pipes - // changes state. The timer is a fallback. Empirically the - // 200 ms safety net was sleeping past wakeups in the browser - // worker (WordPress install.php measured 28 s with 200 ms, - // 1.9 s with 10 ms — a 14× difference far in excess of what - // 5 fallback fires can explain, suggesting setImmediate-based - // broad wakes occasionally lose against the timer in the - // browser's MessageChannel polyfill). 10 ms matches the default - // for read-like / write-like blocking retries. - const hasTargetedWake = pipeIndices.length > 0 || acceptIndices.length > 0; - const retryMs = hasTargetedWake - ? (deadline > 0 ? Math.min(deadline - Date.now(), 10) : 10) - : (deadline > 0 ? Math.min(deadline - Date.now(), 50) : 50); - const timer = setTimeout(retryFn, Math.max(retryMs, 1)); - this.pendingPollRetries.set(channel.channelOffset, { + const retryMs = deadline > 0 ? Math.min(Math.max(deadline - Date.now(), 1), 10) : 10; + const timer = setTimeout(retryFn, retryMs); + this.pendingPollRetries.set(channel, { timer, channel, - pipeIndices, - acceptIndices, - needsSignalSafeWake, + pipeIndices: [], deadline, }); return; } - // (epoll_pwait is now handled entirely on the host side by handleEpollPwait) + // EPOLL event flags → poll event flags + const EPOLLIN = 0x001; + const EPOLLOUT = 0x004; + const EPOLLERR = 0x008; + const EPOLLHUP = 0x010; + const POLLIN = 0x001; + const POLLOUT = 0x004; + const POLLERR = 0x008; + const POLLHUP = 0x010; - // sigtimedwait: kernel returned EAGAIN because no signal is pending. - // Instead of busy-retrying, delay for the requested timeout then complete - // with -1/EAGAIN. - if (syscallNr === SYS_RT_SIGTIMEDWAIT) { - const timeoutPtr = origArgs[2]; // pointer to timespec in process memory - if (timeoutPtr === 0) { - // NULL timeout = wait indefinitely. Use long retry interval since - // signals arrive via kernel_kill, not organically. In the browser, - // short retries starve the event loop when multiple threads are active - // (e.g. MariaDB's signal handler thread). 500ms is adequate because - // cross-process signals are rare, and immediate delivery for kill() - // works via scheduleWakeBlockedRetries. - setTimeout(() => { - if (this.processes.has(channel.pid)) { - this.retrySyscall(channel); - } - }, 500); - return; - } - const pv = new DataView(channel.memory.buffer, timeoutPtr); - // timespec: i64 sec + i64 nsec (time64) - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - const timeoutMs = sec * 1000 + Math.floor(nsec / 1_000_000); - const EAGAIN_ERRNO = 11; - if (timeoutMs <= 0) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); - } else { - setTimeout(() => { - if (this.processes.has(channel.pid)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN_ERRNO); - } - }, timeoutMs); - } - return; - } + // Build pollfds in kernel scratch data area + // struct pollfd = { fd: i32, events: i16, revents: i16 } = 8 bytes + const nfds = interests.length; + const pollfdSize = nfds * 8; - // Non-blocking FD check: if the FD has O_NONBLOCK set, return EAGAIN - // immediately instead of retrying. This is critical for programs like - // nginx that use non-blocking I/O and expect EAGAIN returned promptly. - // Also honor MSG_DONTWAIT on socket send/recv syscalls; unlike O_NONBLOCK, - // it lives only in the syscall arguments and should not enter the retry path. - if (syscallHasMsgDontwait(syscallNr, origArgs)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN); + if (pollfdSize > CH_DATA_SIZE) { + // Too many fds — unlikely but handle gracefully + this.completeChannelRaw(channel, -22, 22); // -EINVAL + this.relistenChannel(channel); return; } - // Covers read/write, accept, accept4, and connect syscalls. - if (READ_LIKE_SYSCALLS.has(syscallNr) || WRITE_LIKE_SYSCALLS.has(syscallNr) - || syscallNr === SYS_ACCEPT || syscallNr === SYS_ACCEPT4 - || syscallNr === SYS_CONNECT) { - const fd = origArgs[0]; - const isFdNonblock = this.kernelInstance!.exports.kernel_is_fd_nonblock as - ((pid: number, fd: number) => number) | undefined; - if (isFdNonblock) { - const nb = isFdNonblock(channel.pid, fd); - if (nb === 1) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN); - return; - } - } + const kernelMem = this.getKernelMem(); + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const dataStart = this.scratchOffset + CH_DATA; + + // Write pollfds to kernel scratch + for (let i = 0; i < nfds; i++) { + const interest = interests[i]; + const off = dataStart + i * 8; + let pollEvents = 0; + if (interest.events & EPOLLIN) pollEvents |= POLLIN; + if (interest.events & EPOLLOUT) pollEvents |= POLLOUT; + new DataView(this.kernelMemory!.buffer).setInt32(off, interest.fd, true); + new DataView(this.kernelMemory!.buffer).setInt16(off + 4, pollEvents, true); + new DataView(this.kernelMemory!.buffer).setInt16(off + 6, 0, true); // revents=0 } - // Non-blocking mqueue check: mq_timedsend/mq_timedreceive return EAGAIN - // from the kernel in both blocking and non-blocking modes (the kernel - // has no way to actually block). For non-blocking descriptors we must - // return EAGAIN to the caller; otherwise the default retry loop spins - // forever waiting for state that will never change (e.g., the final - // mq_receive in tests/sortix/os-test/basic/mqueue/mq_receive.c after mq_setattr sets - // O_NONBLOCK on an empty queue). - if (syscallNr === SYS_MQ_TIMEDSEND || syscallNr === SYS_MQ_TIMEDRECEIVE) { - const mqd = origArgs[0]; - const isFdNonblock = this.kernelInstance!.exports.kernel_is_fd_nonblock as - ((pid: number, fd: number) => number) | undefined; - if (isFdNonblock && isFdNonblock(channel.pid, mqd) === 1) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EAGAIN); - return; - } + // Call kernel with SYS_POLL: (fds_ptr, nfds, timeout_ms=0) + // Always use timeout=0 — we manage blocking/retry on the host side + kernelView.setUint32(CH_SYSCALL, SYS_POLL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(nfds), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(0), true); // timeout=0 for non-blocking poll + for (let i = 3; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - // Socket timeout check: if a read/write-like syscall blocks on a socket - // with SO_RCVTIMEO or SO_SNDTIMEO set, schedule a timer for ETIMEDOUT. - if (READ_LIKE_SYSCALLS.has(syscallNr) || WRITE_LIKE_SYSCALLS.has(syscallNr)) { - const fd = origArgs[0]; - const getTimeout = this.kernelInstance!.exports.kernel_get_socket_timeout_ms as - ((pid: number, fd: number, isRecv: number) => bigint) | undefined; - if (getTimeout && !this.socketTimeoutTimers.has(channel)) { - const isRecv = READ_LIKE_SYSCALLS.has(syscallNr) ? 1 : 0; - const timeoutMs = Number(getTimeout(channel.pid, fd, isRecv)); - if (timeoutMs > 0) { - const timer = setTimeout(() => { - this.socketTimeoutTimers.delete(channel); - // Remove from pending pipe readers if registered - this.removePendingPipeReader(channel); - if (this.processes.has(channel.pid)) { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, ETIMEDOUT); - } - }, timeoutMs); - this.socketTimeoutTimers.set(channel, timer); - } - } + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; } - // Event-driven pipe/socket wakeup: if this is a read-like syscall on a - // pipe/socket fd, register the reader so a matching write can wake it - // immediately instead of polling via setImmediate. - if (READ_LIKE_SYSCALLS.has(syscallNr)) { - const fd = origArgs[0]; - const getFdPipeIdx = this.kernelInstance!.exports.kernel_get_fd_pipe_idx as - ((pid: number, fd: number) => number) | undefined; - if (getFdPipeIdx) { - const pipeIdx = getFdPipeIdx(channel.pid, fd); - if (pipeIdx >= 0) { - let readers = this.pendingPipeReaders.get(pipeIdx); - if (!readers) { - readers = []; - this.pendingPipeReaders.set(pipeIdx, readers); - } - // Avoid duplicate registrations for the same channel - if (!readers.some(r => r.channel === channel)) { - readers.push({ channel, pid: channel.pid }); - } - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; - } - return; - } - } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + + // This host-side emulation performs a nonblocking poll and owns the + // wait/retry loop, so it must preserve the syscall-boundary signal + // outcome that kernel_handle_channel would normally return to the guest. + // A default terminating action leaves an exited kernel Process and must + // reap the worker without waking guest code. A caught handler interrupts + // epoll with EINTR so the glue can run the copied handler metadata before + // the application decides whether to restart the wait. + if (this.completeEpollSignalOutcome(channel)) return; + + // If poll returned error (not EAGAIN), propagate it + if (retVal < 0 && errVal !== EAGAIN) { + this.completeChannelRaw(channel, retVal, errVal); + this.relistenChannel(channel); + return; } - // Event-driven pipe/socket wakeup for writes: if a write-like syscall - // blocks because the pipe/socket send buffer is full, register the writer - // so a matching read (draining the pipe) can wake it immediately. - if (WRITE_LIKE_SYSCALLS.has(syscallNr)) { - const fd = origArgs[0]; - const getSendPipeIdx = this.kernelInstance!.exports.kernel_get_fd_send_pipe_idx as - ((pid: number, fd: number) => number) | undefined; - if (getSendPipeIdx) { - const pipeIdx = getSendPipeIdx(channel.pid, fd); - if (pipeIdx >= 0) { - let writers = this.pendingPipeWriters.get(pipeIdx); - if (!writers) { - writers = []; - this.pendingPipeWriters.set(pipeIdx, writers); - } - if (!writers.some(w => w.channel === channel)) { - writers.push({ channel, pid: channel.pid }); - } - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; - } - return; + // Count ready events and map back to epoll_event format + let readyCount = 0; + if (retVal > 0) { + const processView = new DataView(channel.memory.buffer); + for (let i = 0; i < nfds && readyCount < maxevents; i++) { + const off = dataStart + i * 8; + const revents = new DataView(this.kernelMemory!.buffer).getInt16(off + 6, true); + if (revents !== 0) { + // Map poll revents back to epoll events + let epEvents = 0; + if (revents & POLLIN) epEvents |= EPOLLIN; + if (revents & POLLOUT) epEvents |= EPOLLOUT; + if (revents & POLLERR) epEvents |= EPOLLERR; + if (revents & POLLHUP) epEvents |= EPOLLHUP; + + // Write epoll_event to process memory: { events: u32, data: u64 } = 12 bytes + const evOff = eventsPtr + readyCount * 12; + processView.setUint32(evOff, epEvents, true); + processView.setBigUint64(evOff + 4, interests[i].data, true); + readyCount++; } } } - // Event-driven wakeup for accept/accept4: register the listening socket's - // accept-readiness token so local connect/injected connection wakes the - // accept immediately instead of waiting for the fallback timer. - if (syscallNr === SYS_ACCEPT || syscallNr === SYS_ACCEPT4) { - const fd = origArgs[0]; - const getAcceptWakeIdx = this.kernelInstance!.exports.kernel_get_fd_accept_wake_idx as - ((pid: number, fd: number) => number) | undefined; - if (getAcceptWakeIdx) { - const acceptIdx = getAcceptWakeIdx(channel.pid, fd); - if (acceptIdx >= 0) { - const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.retrySyscall(channel); - } - }; - const timer = setTimeout(retryFn, 10); - this.pendingPollRetries.set(channel.channelOffset, { - timer, - channel, - pipeIndices: [], - acceptIndices: [acceptIdx], - }); - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; - } - return; - } - } + // If we got events, return them + if (readyCount > 0) { + this.completeChannelRaw(channel, readyCount, 0); + this.relistenChannel(channel); + return; } - if (PROFILING) { - const entry = this.profileData!.get(syscallNr); - if (entry) entry.retries++; + // No events ready — handle timeout + if (timeoutMs === 0) { + // Non-blocking: return 0 events + this.completeChannelRaw(channel, 0, 0); + this.relistenChannel(channel); + return; + } + if (deadline > 0 && Date.now() >= deadline) { + // The nonblocking kernel poll above was the final readiness check. + this.completeChannelRaw(channel, 0, 0); + this.relistenChannel(channel); + return; } - // Default: retry via setTimeout to avoid starving other processes. - // Register in pendingPollRetries so wakeAllBlockedRetries can cancel - // the timer and retry immediately when state changes. + // Blocking: retry via setTimeout to avoid starving other processes. + // Pipe-based wakeup (via wakeAllBlockedRetries) provides instant wakeup + // when data arrives; setTimeout is only a fallback. + const { pipeIndices, acceptIndices } = this.resolveEpollReadinessIndices(channel.pid); + const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.retrySyscall(channel); + const pending = this.pendingPollRetries.get(channel); + if (!pending || pending.timer !== timer) return; + this.pendingPollRetries.delete(channel); + if (this.isRegisteredChannel(channel)) { + this.handleEpollPwait(channel, syscallNr, origArgs); } }; - const timer = setTimeout(retryFn, 10); - this.pendingPollRetries.set(channel.channelOffset, { timer, channel, pipeIndices: [] }); + const retryMs = deadline > 0 ? Math.min(Math.max(deadline - Date.now(), 1), 10) : 10; + const timer = setTimeout(retryFn, retryMs); + this.pendingPollRetries.set(channel, { + timer, + channel, + pipeIndices, + acceptIndices, + deadline, + }); + } + + // ---- Network interface ioctl host-side handlers ---- + + private finishNetworkIoctl( + channel: ChannelInfo, + retVal = 0, + errno = 0, + ): void { + this.completeChannelRaw(channel, retVal, errno); + this.relistenChannel(channel); + } + + private guestRangeIsValid( + channel: ChannelInfo, + ptr: number, + length: number, + ): boolean { + return Number.isSafeInteger(ptr) && + Number.isSafeInteger(length) && + ptr >= 0 && + length >= 0 && + ptr <= channel.memory.buffer.byteLength - length; + } + + private interfaceAddress( + iface: (typeof VIRTUAL_INTERFACES)[number], + ): Uint8Array | null { + if (iface.loopback) return new Uint8Array([127, 0, 0, 1]); + const address = this.io.network?.localAddress; + return address?.length === 4 ? new Uint8Array(address) : null; } /** - * Retry a syscall by re-invoking handleSyscall with the original - * args still in the process channel. + * `struct ifreq` has a 16-byte name followed by a union. The union is 16 + * bytes under wasm32, but its `struct ifmap` member grows to 24 bytes under + * wasm64 because `unsigned long` is pointer-sized. */ - private retrySyscall(channel: ChannelInfo): void { - // Check if the process was killed by a signal while blocking. - // This handles cases like sigsuspend + cross-process SIGABRT where - // deliver_pending_signals marks the target as Exited. - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - if (getExitStatus) { - const exitStatus = getExitStatus(channel.pid); - if (exitStatus >= 128) { - this.handleProcessTerminated(channel); - return; - } + private ifreqSize(channel: ChannelInfo): number { + return this.getPtrWidth(channel.pid) === 8 ? 40 : 32; + } + + private readIfreqName(channel: ChannelInfo, ifreqPtr: number): string | null { + if (!this.guestRangeIsValid(channel, ifreqPtr, this.ifreqSize(channel))) { + return null; } + const bytes = new Uint8Array(channel.memory.buffer, ifreqPtr, IF_NAMESIZE); + let end = 0; + while (end < bytes.length && bytes[end] !== 0) end++; + return new TextDecoder().decode(new Uint8Array(bytes.subarray(0, end))); + } - // The process channel still has the original args (we never wrote a response). - // Just re-handle it. - this.handleSyscall(channel); + private writeIfreqName( + processMem: Uint8Array, + ifreqPtr: number, + name: string, + ): void { + const nameBytes = new TextEncoder().encode(name); + processMem.fill(0, ifreqPtr, ifreqPtr + IF_NAMESIZE); + processMem.set(nameBytes.subarray(0, IF_NAMESIZE - 1), ifreqPtr); } /** - * Handle sleep syscalls where the kernel returns success immediately - * but we need to delay the channel response. - * Returns true if this is a sleep syscall that was handled. + * Handle SIOCGIFCONF: enumerate network interfaces. + * struct ifconf { int ifc_len; union { char *ifc_buf; struct ifreq *ifc_req; }; } + * The ifc_buf pointer is in process memory, so the kernel can't write to it + * directly — we handle the entire ioctl on the host side. */ - private handleSleepDelay( - channel: ChannelInfo, - syscallNr: number, - origArgs: number[], - retVal: number, - errVal: number, - ): boolean { - let delayMs = 0; + private handleIoctlIfconf(channel: ChannelInfo, origArgs: number[]): void { + const pw = this.getPtrWidth(channel.pid); + const ifconfPtr = origArgs[2]; + const ifconfSize = pw === 8 ? 16 : 8; + if (!this.guestRangeIsValid(channel, ifconfPtr, ifconfSize)) { + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return; + } - if (syscallNr === SYS_NANOSLEEP && retVal >= 0) { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const sec = kernelView.getUint32(CH_DATA, true); - const nsec = kernelView.getUint32(CH_DATA + 8, true); - delayMs = sec * 1000 + Math.floor(nsec / 1_000_000); - } else if (syscallNr === SYS_USLEEP && retVal >= 0) { - const usec = origArgs[0] >>> 0; - delayMs = Math.max(1, Math.floor(usec / 1000)); - } else if (syscallNr === SYS_CLOCK_NANOSLEEP && retVal >= 0) { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const sec = kernelView.getUint32(CH_DATA, true); - const nsec = kernelView.getUint32(CH_DATA + 8, true); - delayMs = sec * 1000 + Math.floor(nsec / 1_000_000); + const processView = new DataView(channel.memory.buffer); + const processMem = new Uint8Array(channel.memory.buffer); + const ifreqSize = this.ifreqSize(channel); + const ifcLen = processView.getInt32(ifconfPtr, true); + if (ifcLen < 0) { + this.finishNetworkIoctl(channel, -EINVAL, EINVAL); + return; + } + let ifcBuf: number; + if (pw === 8) { + ifcBuf = Number(processView.getBigUint64(ifconfPtr + 8, true)); + } else { + ifcBuf = processView.getUint32(ifconfPtr + 4, true); } - if (delayMs > 0) { - const timer = setTimeout(() => { - this.pendingSleeps.delete(channel.pid); - if (this.processes.has(channel.pid)) { - this.completeSleepWithSignalCheck(channel, syscallNr, origArgs, retVal, errVal); - } - }, delayMs); - this.pendingSleeps.set(channel.pid, { timer, channel, syscallNr, origArgs, retVal, errVal }); - return true; + if (ifcBuf === 0) { + processView.setInt32( + ifconfPtr, + VIRTUAL_INTERFACES.length * ifreqSize, + true, + ); + this.finishNetworkIoctl(channel); + return; } - return false; + if (ifcLen < ifreqSize) { + processView.setInt32(ifconfPtr, 0, true); + this.finishNetworkIoctl(channel); + return; + } + + const capacity = Math.floor(ifcLen / ifreqSize); + const count = Math.min(capacity, VIRTUAL_INTERFACES.length); + const bytesToWrite = count * ifreqSize; + if (!this.guestRangeIsValid(channel, ifcBuf, bytesToWrite)) { + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return; + } + + for (let i = 0; i < count; i++) { + const iface = VIRTUAL_INTERFACES[i]; + const entryPtr = ifcBuf + i * ifreqSize; + this.writeIfreqName(processMem, entryPtr, iface.name); + processMem.fill(0, entryPtr + IF_NAMESIZE, entryPtr + ifreqSize); + processView.setUint16(entryPtr + IF_NAMESIZE, AF_INET, true); + const address = this.interfaceAddress(iface); + if (address) processMem.set(address, entryPtr + IF_NAMESIZE + 4); + } + processView.setInt32(ifconfPtr, bytesToWrite, true); + this.finishNetworkIoctl(channel); } /** - * Complete a sleep syscall, checking for pending signals first. - * POSIX: sleep interrupted by signal returns EINTR. + * Handle SIOCGIFNAME: map an interface index to its name. + * struct ifreq at arg[2]: ifr_name[16] + union; ifr_ifindex lives at +16. */ - private completeSleepWithSignalCheck( - channel: ChannelInfo, - syscallNr: number, - origArgs: number[], - retVal: number, - errVal: number, - ): void { - // Check if a signal became pending during the sleep - this.dequeueSignalForDelivery(channel); + private handleIoctlIfname(channel: ChannelInfo, origArgs: number[]): void { + const ifreqPtr = origArgs[2]; + if (!this.guestRangeIsValid(channel, ifreqPtr, this.ifreqSize(channel))) { + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return; + } + const processView = new DataView(channel.memory.buffer); + const processMem = new Uint8Array(channel.memory.buffer); + const ifindex = processView.getInt32(ifreqPtr + 16, true); + const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.index === ifindex); - // If a signal was dequeued, return EINTR instead of success - const processView = new DataView(channel.memory.buffer, channel.channelOffset); - const pendingSig = processView.getUint32(CH_SIG_SIGNUM, true); - if (pendingSig > 0) { - // POSIX: nanosleep/usleep interrupted by signal returns -1/EINTR - const EINTR = 4; - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], -1, EINTR); - } else { - this.completeChannel(channel, syscallNr, origArgs, SYSCALL_ARGS[syscallNr], retVal, errVal); + if (!iface) { + this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + return; } - } - // ----------------------------------------------------------------------- - // Scatter/gather I/O handling (writev/readv/pwritev/preadv) - // - // These syscalls use struct iovec arrays with nested pointers: - // struct iovec { void *iov_base; size_t iov_len; } (8 bytes on wasm32) - // Both the iov array AND each iov_base buffer must be in kernel memory. - // ----------------------------------------------------------------------- + this.writeIfreqName(processMem, ifreqPtr, iface.name); + this.finishNetworkIoctl(channel); + } /** - * Handle writev/pwritev: copy iov array and all data buffers from - * process memory into kernel scratch, then call kernel_handle_channel. + * Handle SIOCGIFHWADDR: get hardware (MAC) address for an interface. + * struct ifreq at arg[2]: ifr_name[16] + ifr_hwaddr (struct sockaddr, 16 bytes) + * Returns the virtual MAC in ifr_hwaddr.sa_data[0..5]. */ + private handleIoctlIfhwaddr(channel: ChannelInfo, origArgs: number[]): void { + const ifreqPtr = origArgs[2]; + const name = this.readIfreqName(channel, ifreqPtr); + if (name === null) { + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return; + } + const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); + if (!iface) { + this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + return; + } + const processView = new DataView(channel.memory.buffer); + const processMem = new Uint8Array(channel.memory.buffer); + + processMem.fill( + 0, + ifreqPtr + IF_NAMESIZE, + ifreqPtr + this.ifreqSize(channel), + ); + processView.setUint16( + ifreqPtr + IF_NAMESIZE, + iface.loopback ? ARPHRD_LOOPBACK : ARPHRD_ETHER, + true, + ); + if (!iface.loopback) { + processMem.set(this.virtualMacAddress, ifreqPtr + IF_NAMESIZE + 2); + } + + this.finishNetworkIoctl(channel); + } + /** - * Handle fcntl lock operations (F_GETLK, F_SETLK, F_SETLKW). - * Arg3 is a pointer to struct flock (32 bytes) which needs copy in/out. + * Handle SIOCGIFADDR: get interface address. + * struct ifreq at arg[2]: ifr_name[16] + ifr_addr (struct sockaddr, 16 bytes) + * Returns the selected virtual interface's assigned IPv4 address. */ - private handleFcntlLock(channel: ChannelInfo, origArgs: number[]): void { - const FLOCK_SIZE = 32; - const flockPtr = origArgs[2]; - + private handleIoctlIfaddr(channel: ChannelInfo, origArgs: number[]): void { + const ifreqPtr = origArgs[2]; + const name = this.readIfreqName(channel, ifreqPtr); + if (name === null) { + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return; + } + const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); + if (!iface) { + this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + return; + } + const address = this.interfaceAddress(iface); + if (!address) { + this.finishNetworkIoctl(channel, -EADDRNOTAVAIL, EADDRNOTAVAIL); + return; + } + const processView = new DataView(channel.memory.buffer); const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - // Copy flock struct from process → kernel scratch - if (flockPtr !== 0) { - kernelMem.set(processMem.subarray(flockPtr, flockPtr + FLOCK_SIZE), dataStart); + processMem.fill( + 0, + ifreqPtr + IF_NAMESIZE, + ifreqPtr + this.ifreqSize(channel), + ); + processView.setUint16(ifreqPtr + IF_NAMESIZE, AF_INET, true); + processMem.set(address, ifreqPtr + IF_NAMESIZE + 4); + + this.finishNetworkIoctl(channel); + } + + /** + * Handle SIOCGIFINDEX: map an interface name to its index. + * struct ifreq at arg[2]: ifr_name[16] + union; ifr_ifindex lives at +16. + */ + private handleIoctlIfindex(channel: ChannelInfo, origArgs: number[]): void { + const ifreqPtr = origArgs[2]; + const name = this.readIfreqName(channel, ifreqPtr); + if (name === null) { + this.finishNetworkIoctl(channel, -EFAULT, EFAULT); + return; } + const iface = VIRTUAL_INTERFACES.find((candidate) => candidate.name === name); - // Write syscall header to kernel scratch - kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(origArgs[0]), true); // fd - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(origArgs[1]), true); // cmd - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flockPtr !== 0 ? dataStart : 0), true); // flock_ptr in kernel memory - for (let i = 3; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(origArgs[i]), true); + if (!iface) { + this.finishNetworkIoctl(channel, -ENODEV, ENODEV); + return; } - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; + new DataView(channel.memory.buffer).setInt32( + ifreqPtr + IF_NAMESIZE, + iface.index, + true, + ); + this.finishNetworkIoctl(channel); + } + + /** + * Ask the kernel for one logical write's complete byte budget before the + * host splits it across scratch-buffer calls. A negative result has already + * generated any required SIGXFSZ in the calling thread; this method finishes + * that syscall boundary and returns null. + */ + private prepareWriteOperationBudget( + channel: ChannelInfo, + fd: number, + offset: number, + requestedLen: number, + positioned: boolean, + ): number | null { + const prepare = this.kernelInstance!.exports.kernel_prepare_write_operation as + | ((pid: number, fd: number, offset: bigint, len: number, positioned: number) => bigint) + | undefined; + if (!prepare) { + throw new Error( + "kernel ABI is missing kernel_prepare_write_operation for chunked writes", + ); + } + + let result: number; this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + result = Number( + prepare(channel.pid, fd, BigInt(offset), requestedLen, positioned ? 1 : 0), + ); + } catch (err) { + console.error( + `[prepareWriteOperationBudget] kernel threw for pid=${channel.pid}:`, + err, + ); + this.completeChannelRaw(channel, -1, EIO); + this.relistenChannel(channel); + return null; } finally { this.currentHandlePid = 0; } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + if (this.finishSignalTermination(channel)) return null; + + if (!Number.isSafeInteger(result) || result > requestedLen) { + console.error( + `[prepareWriteOperationBudget] invalid kernel budget ${result} for request ${requestedLen}`, + ); + this.completeChannelRaw(channel, -1, EIO); + this.relistenChannel(channel); + return null; + } + if (result < 0) { + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return null; + this.completeChannelRaw(channel, -1, -result); + this.relistenChannel(channel); + return null; + } + return result; + } + + private handleWritev(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const fd = origArgs[0]; + const iovPtr = origArgs[1]; + const iovcnt = origArgs[2]; + + const processMem = new Uint8Array(channel.memory.buffer); + const processView = new DataView(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const dataStart = this.scratchOffset + CH_DATA; + + // iovec struct: { void* iov_base, size_t iov_len } + // wasm32: 8 bytes per entry (4+4), wasm64: 16 bytes per entry (8+8) + const pw = this.getPtrWidth(channel.pid); + const iovEntrySize = pw === 8 ? 16 : 8; - // Copy flock struct back from kernel → process (F_GETLK writes to it) - if (flockPtr !== 0 && retVal >= 0) { - const freshProcessMem = new Uint8Array(channel.memory.buffer); - freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FLOCK_SIZE), flockPtr); + if (iovcnt <= 0 || iovcnt > 1024) { + this.completeChannelRaw(channel, -1, EINVAL); + this.relistenChannel(channel); + return; } - const cmd = origArgs[1]; - if ( - retVal === -1 && - errVal === EAGAIN && - (cmd === F_SETLKW || cmd === F_SETLKW64 || cmd === F_OFD_SETLKW) - ) { - this.handleBlockingRetry(channel, SYS_FCNTL, origArgs); + // Read iov entries from process memory + interface IovEntry { base: number; len: number } + const entries: IovEntry[] = []; + let totalData = 0; + for (let i = 0; i < iovcnt; i++) { + let base: number, len: number; + if (pw === 8) { + base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); + len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); + } else { + base = processView.getUint32(iovPtr + i * iovEntrySize, true); + len = processView.getUint32(iovPtr + i * iovEntrySize + 4, true); + } + entries.push({ base, len }); + totalData += len; + } + if (!Number.isSafeInteger(totalData) || totalData > 0x7FFFFFFF) { + this.completeChannelRaw(channel, -1, EINVAL); + this.relistenChannel(channel); return; } - this.completeChannel(channel, SYS_FCNTL, origArgs, undefined, retVal, errVal); - } + // Max data that fits in scratch: CH_DATA_SIZE minus space for iov entries + const iovSize = iovcnt * 8; + const maxDataPerCall = CH_DATA_SIZE - iovSize; - /** - * Handle pselect6: copy fd_sets (inout), decode timeout/sigmask from - * process memory, call kernel_handle_channel, copy fd_sets back. - * - * Layout in kernel scratch data area: - * [0..128] readfds (fd_set, 128 bytes) - * [128..256] writefds (fd_set, 128 bytes) - * [256..384] exceptfds (fd_set, 128 bytes) - * [384..392] mask (8 bytes: mask_lo + mask_hi) - */ - /** - * select(2) — args (nfds, readfds, writefds, exceptfds, *timeval). - * - * Differs from pselect6 only in the time struct: select takes - * `struct timeval { long sec; long usec; }` and has no sigmask. musl - * routes here on wasm64 because `__NR_pselect6_time64` isn't defined for - * that arch (unlike wasm32, which aliases it to __NR_pselect6 and lands - * on pselect6 instead). - * - * We decode timeval → ms, drive the kernel with sys_select directly, and - * mirror handlePselect6's EAGAIN/timeout bookkeeping. The hot path in our - * own code is `select(0, NULL, NULL, NULL, &tv)` (mysys/my_sleep.c) — the - * pure-sleep case, fast-path'd to a setTimeout. - */ - private handleSelect(channel: ChannelInfo, origArgs: number[]): void { - const FD_SET_SIZE = 128; - const nfds = origArgs[0]; - const readPtr = origArgs[1]; - const writePtr = origArgs[2]; - const exceptPtr = origArgs[3]; - const tvPtr = origArgs[4]; + if (totalData <= maxDataPerCall) { + // Fast path: all data fits in one kernel call + let dataOff = iovSize; - let timeoutMs = -1; // -1 = infinite (NULL timeval) - if (tvPtr !== 0) { - const ptrWidth = this.getPtrWidth(channel.pid); - const pv = new DataView(channel.memory.buffer, tvPtr); - let sec: number, usec: number; - if (ptrWidth === 8) { - sec = Number(pv.getBigInt64(0, true)); - usec = Number(pv.getBigInt64(8, true)); - } else { - sec = pv.getInt32(0, true); - usec = pv.getInt32(4, true); + for (let i = 0; i < iovcnt; i++) { + const kernelBase = dataStart + dataOff; + + if (entries[i].len > 0) { + kernelMem.set(processMem.subarray(entries[i].base, entries[i].base + entries[i].len), kernelBase); + } + + const iovAddr = dataStart + i * 8; + new DataView(kernelMem.buffer).setUint32(iovAddr, kernelBase, true); + new DataView(kernelMem.buffer).setUint32(iovAddr + 4, entries[i].len, true); + + dataOff += entries[i].len; + dataOff = (dataOff + 3) & ~3; // align } - timeoutMs = sec * 1000 + Math.floor(usec / 1000); - if (timeoutMs < 0) timeoutMs = 0; - } - // Pure-sleep fast path: select(0, NULL, NULL, NULL, &tv) is `my_sleep`. - // The kernel can't tell us anything new — there are no fds to poll — - // so we just wait the timeout and return 0. Tracked in - // pendingSelectRetries so a cross-process kill can break us out early - // (handleKill -> scheduleWakeBlockedRetries -> wakeAllBlockedRetries - // already iterates pendingSelectRetries entries). - if (nfds === 0 && readPtr === 0 && writePtr === 0 && exceptPtr === 0) { - if (timeoutMs === 0) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(iovcnt), true); + if (syscallNr === SYS_PWRITEV) { + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(origArgs[3]), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(origArgs[4]), true); + } + + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } + + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + + if (retVal === -1 && errVal === EAGAIN) { + this.handleBlockingRetry(channel, syscallNr, origArgs); return; } - const finite = timeoutMs > 0; - const timer = finite - ? setTimeout(() => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); - } - }, timeoutMs) - : (null as any); - this.pendingSelectRetries.set(channel.channelOffset, { - timer, + + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, retVal, errVal, + ); + this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); + } else { + // Slow path: total data exceeds scratch buffer. Issue individual SYS_WRITEV + // calls with one iov entry each, chunked to fit in CH_DATA_SIZE. + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const isPwritev = syscallNr === SYS_PWRITEV; + let fileOffset = isPwritev + ? (origArgs[3] >>> 0) + (origArgs[4] | 0) * 0x100000000 + : 0; + const operationLen = this.prepareWriteOperationBudget( channel, - origArgs, - deadline: finite ? Date.now() + timeoutMs : -1, - needsSignalSafeWake: false, - syscallNr: SYS_SELECT, - }); - return; - } + fd, + fileOffset, + totalData, + isPwritev, + ); + if (operationLen === null) return; + let totalWritten = 0; + let gotEagain = false; + let firstError: { retVal: number; errVal: number } | null = null; + const maxChunk = CH_DATA_SIZE - 8; // space for 1 iov entry (8B) + data - // General case: dispatch to the kernel's sys_select with timeout_ms in - // arg5. fd_sets are copied via the standard pre-existing scratch flow - // (kernel_select reads readfds_ptr/writefds_ptr/exceptfds_ptr into - // process memory directly, so we copy them in just like handlePselect6). - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; + for (const entry of entries) { + if (totalWritten >= operationLen) break; + if (entry.len === 0) continue; + let entryWritten = 0; - if (readPtr !== 0) { - kernelMem.set(processMem.subarray(readPtr, readPtr + FD_SET_SIZE), dataStart); - } else { - kernelMem.fill(0, dataStart, dataStart + FD_SET_SIZE); - } - if (writePtr !== 0) { - kernelMem.set(processMem.subarray(writePtr, writePtr + FD_SET_SIZE), dataStart + FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE); - } - if (exceptPtr !== 0) { - kernelMem.set(processMem.subarray(exceptPtr, exceptPtr + FD_SET_SIZE), dataStart + 2 * FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE); - } + while (entryWritten < entry.len && totalWritten < operationLen) { + const chunkLen = Math.min( + entry.len - entryWritten, + maxChunk, + operationLen - totalWritten, + ); + const kernelBuf = dataStart + 8; // single iov entry at dataStart, data after - kernelView.setUint32(CH_SYSCALL, SYS_SELECT, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(nfds), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(timeoutMs), true); + // Copy data from process to kernel scratch + kernelMem.set( + processMem.subarray(entry.base + entryWritten, entry.base + entryWritten + chunkLen), + kernelBuf, + ); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + // Set up single iov entry + new DataView(kernelMem.buffer).setUint32(dataStart, kernelBuf, true); + new DataView(kernelMem.buffer).setUint32(dataStart + 4, chunkLen, true); - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + if (isPwritev) { + kernelView.setUint32(CH_SYSCALL, SYS_PWRITEV, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xFFFFFFFF), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000)), true); + } else { + kernelView.setUint32(CH_SYSCALL, SYS_WRITEV, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); + } - // Copy fd_sets back from kernel → process on success - if (retVal >= 0) { - const freshProcessMem = new Uint8Array(channel.memory.buffer); - if (readPtr !== 0) { - freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FD_SET_SIZE), readPtr); + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } + + if (this.finishSignalTermination(channel)) return; + + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); + + if (retVal === -1) { + if (errVal === EAGAIN && totalWritten === 0) { + gotEagain = true; + } else if (totalWritten === 0) { + firstError = { retVal, errVal }; + } + break; + } + + entryWritten += retVal; + totalWritten += retVal; + if (isPwritev) fileOffset += retVal; + + if (retVal < chunkLen) break; // short write (e.g. pipe full) + } + + if (gotEagain || entryWritten < entry.len) break; } - if (writePtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE), - writePtr, - ); + + if (gotEagain) { + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; } - if (exceptPtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE), - exceptPtr, - ); + if (firstError) { + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.completeChannelRaw(channel, firstError.retVal, firstError.errVal); + this.relistenChannel(channel); + return; } - } - this.dequeueSignalForDelivery(channel); + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalWritten, 0); + this.relistenChannel(channel); + } + } - // EAGAIN retry for blocking select. Mirrors handlePselect6. - if (retVal === -1 && errVal === EAGAIN) { - if (timeoutMs === 0) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); - return; - } - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : -1; - const retryFn = () => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; - if (deadline > 0 && Date.now() >= deadline) { - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, 0, 0); - return; - } - this.handleSelect(channel, origArgs); - }; - const finite = timeoutMs > 0; - const remainingMs = finite ? Math.max(deadline - Date.now(), 1) : 50; - const timer = setTimeout(retryFn, Math.min(remainingMs, 50)); - this.pendingSelectRetries.set(channel.channelOffset, { - timer, channel, origArgs, deadline, needsSignalSafeWake: false, - syscallNr: SYS_SELECT, - }); + /** + * Handle large write/pwrite where the data exceeds CH_DATA_SIZE. + * Loops through CH_DATA_SIZE chunks, issuing individual kernel calls. + */ + private handleLargeWrite(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const fd = origArgs[0]; + const bufPtr = origArgs[1]; + const totalLen = origArgs[2]; + if ( + !Number.isSafeInteger(totalLen) || + totalLen < 0 || + totalLen > 0x7FFFFFFF + ) { + this.completeChannelRaw(channel, -1, EINVAL); + this.relistenChannel(channel); return; } + const isPwrite = syscallNr === SYS_PWRITE; + // pwrite offset is a single i64 arg (arg index 3) + let fileOffset = isPwrite ? origArgs[3] : 0; + const operationLen = this.prepareWriteOperationBudget( + channel, + fd, + fileOffset, + totalLen, + isPwrite, + ); + if (operationLen === null) return; - this.completeChannel(channel, SYS_SELECT, origArgs, undefined, retVal, errVal); - } - - private handlePselect6(channel: ChannelInfo, origArgs: number[]): void { - const FD_SET_SIZE = 128; const processMem = new Uint8Array(channel.memory.buffer); const kernelMem = this.getKernelMem(); const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); const dataStart = this.scratchOffset + CH_DATA; - - const nfds = origArgs[0]; - const readPtr = origArgs[1]; - const writePtr = origArgs[2]; - const exceptPtr = origArgs[3]; - const tsPtr = origArgs[4]; - const maskDataPtr = origArgs[5]; // pointer to {sigset_t *mask, size_t size} - - // Copy fd_sets from process → kernel scratch - if (readPtr !== 0) { - kernelMem.set(processMem.subarray(readPtr, readPtr + FD_SET_SIZE), dataStart); - } else { - kernelMem.fill(0, dataStart, dataStart + FD_SET_SIZE); - } - if (writePtr !== 0) { - kernelMem.set(processMem.subarray(writePtr, writePtr + FD_SET_SIZE), dataStart + FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE); - } - if (exceptPtr !== 0) { - kernelMem.set(processMem.subarray(exceptPtr, exceptPtr + FD_SET_SIZE), dataStart + 2 * FD_SET_SIZE); - } else { - kernelMem.fill(0, dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE); - } - - // Decode timeout: timespec {i64 sec, i64 nsec} → ms - let timeoutMs = -1; - if (tsPtr !== 0) { - const pv = new DataView(channel.memory.buffer, tsPtr); - const sec = Number(pv.getBigInt64(0, true)); - const nsec = Number(pv.getBigInt64(8, true)); - timeoutMs = sec * 1000 + Math.floor(nsec / 1000000); - } - - // Decode sigmask: pselect6 arg6 → pointer to {sigset_t *mask, size_t size} - // On wasm32: {u32 mask_ptr, u32 size} = 8 bytes - // On wasm64: {u64 mask_ptr, u64 size} = 16 bytes - // - // POSIX pselect6 semantics: arg6 points at `{const sigset_t *ss, size_t - // ss_len}`. If `ss == NULL`, the syscall must NOT swap the signal mask - // (callers like glibc's `select(2)` wrapper pass a non-NULL outer struct - // with `ss=NULL` to use the unified syscall path without requesting a - // mask swap). We mirror that here by treating "inner mask NULL" the same - // as "outer struct NULL": don't pass a mask-pointer to the kernel, so - // sys_pselect6 leaves `mask=None` and skips the temp-mask path. Without - // this, mariadbd's `select()` from main blew its sigmask away to 0 every - // call, letting the next kill(getpid, SIGTERM) fire the main-thread - // handler before the dedicated `signal_hand` thread could `sigwait` it - // — `wait_for_signal_thread_to_end` then spun forever. - const maskOffset = dataStart + 3 * FD_SET_SIZE; - let kernelMaskPtr = 0; // 0 = no mask swap - if (maskDataPtr !== 0) { - const pw = this.getPtrWidth(channel.pid); - const mdv = new DataView(channel.memory.buffer, maskDataPtr); - const maskPtr = pw === 8 - ? Number(mdv.getBigUint64(0, true)) - : mdv.getUint32(0, true); - if (maskPtr !== 0) { - kernelMem.set(processMem.subarray(maskPtr, maskPtr + 8), maskOffset); - kernelMaskPtr = maskOffset; - } - } - - // Write args: (nfds, readfds_kernel_ptr, writefds_kernel_ptr, - // exceptfds_kernel_ptr, timeout_ms, mask_kernel_ptr) - kernelView.setUint32(CH_SYSCALL, SYS_PSELECT6, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(nfds), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(readPtr !== 0 ? dataStart : 0), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(writePtr !== 0 ? dataStart + FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(exceptPtr !== 0 ? dataStart + 2 * FD_SET_SIZE : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(timeoutMs), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(kernelMaskPtr), true); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - // pselect6 debug logging disabled + let totalWritten = 0; - // Copy fd_sets back from kernel → process - if (retVal >= 0) { - const freshProcessMem = new Uint8Array(channel.memory.buffer); - if (readPtr !== 0) { - freshProcessMem.set(kernelMem.subarray(dataStart, dataStart + FD_SET_SIZE), readPtr); - } - if (writePtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + FD_SET_SIZE, dataStart + 2 * FD_SET_SIZE), - writePtr, - ); - } - if (exceptPtr !== 0) { - freshProcessMem.set( - kernelMem.subarray(dataStart + 2 * FD_SET_SIZE, dataStart + 3 * FD_SET_SIZE), - exceptPtr, - ); - } - } + while (totalWritten < operationLen) { + const chunkLen = Math.min(operationLen - totalWritten, CH_DATA_SIZE); - // Handle signal delivery - this.dequeueSignalForDelivery(channel); + // Copy chunk from process memory to kernel scratch + kernelMem.set( + processMem.subarray(bufPtr + totalWritten, bufPtr + totalWritten + chunkLen), + dataStart, + ); - // Handle EAGAIN retry for blocking select - if (retVal === -1 && errVal === EAGAIN) { - if (timeoutMs === 0) { - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); - return; + // Set up syscall in kernel scratch + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkLen), true); + if (isPwrite) { + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); } - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : -1; - // pselect6 with a non-null sigmask pointer has the same late-signal - // race as ppoll. See scheduleWakeBlockedRetriesDeferred. - const needsSignalSafeWake = maskDataPtr !== 0; - - // nfds=0: pure sleep/sigsuspend-like behavior. - // With finite timeout: sleep for that duration. - // With infinite timeout: block until signal (wakeAllBlockedRetries). - if (nfds === 0) { - if (timeoutMs > 0) { - const timer = setTimeout(() => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); - } - }, timeoutMs); - this.pendingSelectRetries.set(channel.channelOffset, { - timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, - }); + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch (err) { + console.error(`[handleLargeWrite] kernel threw for pid=${channel.pid}:`, err); + if (totalWritten > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalWritten, 0); } else { - // Infinite timeout with nfds=0: wait for signal delivery. - // No timer — wakeAllBlockedRetries will trigger the retry. - this.pendingSelectRetries.set(channel.channelOffset, { - timer: null as any, channel, origArgs, deadline: -1, - needsSignalSafeWake, syscallNr: SYS_PSELECT6, - }); + this.completeChannelRaw(channel, -5, 5); // -EIO } + this.relistenChannel(channel); return; + } finally { + this.currentHandlePid = 0; } - // For finite timeout with actual fds, track the deadline - const retryFn = () => { - this.pendingSelectRetries.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; - if (deadline > 0 && Date.now() >= deadline) { - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, 0, 0); - return; - } - this.handlePselect6(channel, origArgs); - }; - const timer = setImmediate(retryFn); - this.pendingSelectRetries.set(channel.channelOffset, { - timer, channel, origArgs, deadline, needsSignalSafeWake, syscallNr: SYS_PSELECT6, - }); - return; - } - - this.completeChannel(channel, SYS_PSELECT6, origArgs, undefined, retVal, errVal); - } - - // ---- epoll host-side implementation ---- - // kernel_handle_channel crashes in Chrome for epoll_pwait (suspected V8 - // shared-memory Wasm bug). We handle all epoll syscalls on the host: - // epoll_create1/create → still call kernel (works fine), mirror result - // epoll_ctl → still call kernel (works fine), mirror interest list - // epoll_pwait → convert to poll entirely on host, no kernel_handle_channel - - /** - * Handle epoll_create1 / epoll_create: let the kernel create the fd, - * then initialise an empty interest list on the host side. - */ - private handleEpollCreate(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const flags = origArgs[0]; + if (this.finishSignalTermination(channel)) return; - // For SYS_EPOLL_CREATE, kernel expects flags=0 (size arg ignored) - const actualFlags = syscallNr === SYS_EPOLL_CREATE ? 0 : flags; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(actualFlags), true); - for (let i = 1; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } + if (retVal === -1 && errVal === EAGAIN) { + if (totalWritten > 0) { + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalWritten, 0); + this.relistenChannel(channel); + return; + } + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; + } - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + if (errVal !== 0 || retVal <= 0) { + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + if (totalWritten > 0) { + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalWritten, 0); + } else { + this.completeChannelRaw(channel, retVal, errVal); + } + this.relistenChannel(channel); + return; + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + totalWritten += retVal; + if (isPwrite) fileOffset += retVal; - // If successful, initialise the host-side interest mirror - if (retVal >= 0) { - const key = `${channel.pid}:${retVal}`; - this.epollInterests.set(key, []); + // Short write from kernel — return what we have + if (retVal < chunkLen) break; } - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.handleSharedMappingsAfterFileSyscall( + channel, syscallNr, origArgs, totalWritten, 0, + ); + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalWritten, 0); + this.relistenChannel(channel); } /** - * Handle epoll_ctl: let the kernel modify its interest list, then mirror - * the change on the host side. + * Handle large read/pread where the buffer exceeds CH_DATA_SIZE. + * Loops through CH_DATA_SIZE chunks, copying data back to process memory. */ - private handleEpollCtl(channel: ChannelInfo, origArgs: number[]): void { - const epfd = origArgs[0]; - const op = origArgs[1]; - const fd = origArgs[2]; - const eventPtr = origArgs[3]; // pointer in process memory - - // Read epoll_event from process memory: { events: u32, data: u64 } = 12 bytes - let events = 0; - let data = 0n; - if (eventPtr !== 0) { - const pv = new DataView(channel.memory.buffer, eventPtr); - events = pv.getUint32(0, true); - data = pv.getBigUint64(4, true); - } + private handleLargeRead(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const fd = origArgs[0]; + const bufPtr = origArgs[1]; + const totalLen = origArgs[2]; + const isPread = syscallNr === SYS_PREAD; + let fileOffset = isPread ? origArgs[3] : 0; - // Call kernel — copy event struct to scratch - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const processMem = new Uint8Array(channel.memory.buffer); const kernelMem = this.getKernelMem(); + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); const dataStart = this.scratchOffset + CH_DATA; - - // Copy 12-byte epoll_event to kernel scratch - if (eventPtr !== 0) { - const processMem = new Uint8Array(channel.memory.buffer); - kernelMem.set(processMem.subarray(eventPtr, eventPtr + 12), dataStart); - } - - kernelView.setUint32(CH_SYSCALL, SYS_EPOLL_CTL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(epfd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(op), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(eventPtr !== 0 ? dataStart : 0), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + let totalRead = 0; - // Mirror the change on the host side if the kernel succeeded - if (retVal === 0) { - const EPOLL_CTL_ADD = 1; - const EPOLL_CTL_DEL = 2; - const EPOLL_CTL_MOD = 3; + while (totalRead < totalLen) { + const chunkLen = Math.min(totalLen - totalRead, CH_DATA_SIZE); - const key = `${channel.pid}:${epfd}`; - let interests = this.epollInterests.get(key); - if (!interests) { - interests = []; - this.epollInterests.set(key, interests); + // Zero the scratch data area for the read output + kernelMem.fill(0, dataStart, dataStart + chunkLen); + + // Set up syscall in kernel scratch + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkLen), true); + if (isPread) { + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); } - if (op === EPOLL_CTL_ADD) { - interests.push({ fd, events, data }); - } else if (op === EPOLL_CTL_DEL) { - const idx = interests.findIndex(e => e.fd === fd); - if (idx >= 0) interests.splice(idx, 1); - } else if (op === EPOLL_CTL_MOD) { - const entry = interests.find(e => e.fd === fd); - if (entry) { - entry.events = events; - entry.data = data; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch (err) { + console.error(`[handleLargeRead] kernel threw for pid=${channel.pid}:`, err); + if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalRead, 0); + } else { + this.completeChannelRaw(channel, -5, 5); // -EIO } + this.relistenChannel(channel); + return; + } finally { + this.currentHandlePid = 0; } - } - - this.completeChannel(channel, SYS_EPOLL_CTL, origArgs, undefined, retVal, errVal); - } - /** - * Handle epoll_pwait / epoll_wait entirely on the host side. - * Converts the epoll interest list to a poll syscall, calls - * kernel_handle_channel with SYS_POLL, then maps results back - * to epoll_event format and writes to process memory. - */ - private handleEpollPwait(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const epfd = origArgs[0]; - const eventsPtr = origArgs[1]; // output pointer in process memory - const maxevents = origArgs[2]; - const timeoutMs = origArgs[3]; - // origArgs[4] = sigmask ptr (process-space), origArgs[5] = sigset size + if (this.finishSignalTermination(channel)) return; - if (maxevents <= 0) { - this.completeChannelRaw(channel, -22, 22); // -EINVAL - this.relistenChannel(channel); - return; - } + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - const key = `${channel.pid}:${epfd}`; - const interests = this.epollInterests.get(key); - if (!interests) { - this.completeChannelRaw(channel, -9, 9); // -EBADF - this.relistenChannel(channel); - return; - } + if (retVal === -1 && errVal === EAGAIN) { + if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalRead, 0); + this.relistenChannel(channel); + return; + } + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; + } - if (interests.length === 0) { - // No interests registered — return 0 immediately for timeout=0, - // or block (EAGAIN) for non-zero timeout. - if (timeoutMs === 0) { - this.completeChannelRaw(channel, 0, 0); + if (errVal !== 0 || retVal <= 0) { + if (totalRead > 0) { + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalRead, 0); + } else { + this.completeChannelRaw(channel, retVal, errVal); + } this.relistenChannel(channel); return; } - // For non-zero timeout with no interests, retry with delay to avoid starvation - const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.handleEpollPwait(channel, syscallNr, origArgs); - } - }; - const timer = setTimeout(retryFn, 10); - this.pendingPollRetries.set(channel.channelOffset, { timer, channel, pipeIndices: [] }); - return; - } - // EPOLL event flags → poll event flags - const EPOLLIN = 0x001; - const EPOLLOUT = 0x004; - const EPOLLERR = 0x008; - const EPOLLHUP = 0x010; - const POLLIN = 0x001; - const POLLOUT = 0x004; - const POLLERR = 0x008; - const POLLHUP = 0x010; + // Copy read data from kernel scratch to process memory + processMem.set( + kernelMem.subarray(dataStart, dataStart + retVal), + bufPtr + totalRead, + ); - // Build pollfds in kernel scratch data area - // struct pollfd = { fd: i32, events: i16, revents: i16 } = 8 bytes - const nfds = interests.length; - const pollfdSize = nfds * 8; + totalRead += retVal; + if (isPread) fileOffset += retVal; - if (pollfdSize > CH_DATA_SIZE) { - // Too many fds — unlikely but handle gracefully - this.completeChannelRaw(channel, -22, 22); // -EINVAL - this.relistenChannel(channel); - return; + // Short read (EOF or partial) — return what we have + if (retVal < chunkLen) break; } + this.dequeueSignalForDelivery(channel); + if (this.finishSignalTermination(channel)) return; + this.synchronizeSharedMemoryForBoundary(channel); + this.completeChannelRaw(channel, totalRead, 0); + this.relistenChannel(channel); + } + + /** + * Handle readv/preadv: set up iov array in kernel scratch, call + * kernel_handle_channel, then copy read data back to process memory. + */ + private handleReadv(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const fd = origArgs[0]; + const iovPtr = origArgs[1]; + const iovcnt = origArgs[2]; + + const processMem = new Uint8Array(channel.memory.buffer); + const processView = new DataView(channel.memory.buffer); const kernelMem = this.getKernelMem(); const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); const dataStart = this.scratchOffset + CH_DATA; - // Write pollfds to kernel scratch - for (let i = 0; i < nfds; i++) { - const interest = interests[i]; - const off = dataStart + i * 8; - let pollEvents = 0; - if (interest.events & EPOLLIN) pollEvents |= POLLIN; - if (interest.events & EPOLLOUT) pollEvents |= POLLOUT; - new DataView(this.kernelMemory!.buffer).setInt32(off, interest.fd, true); - new DataView(this.kernelMemory!.buffer).setInt16(off + 4, pollEvents, true); - new DataView(this.kernelMemory!.buffer).setInt16(off + 6, 0, true); // revents=0 + // iovec struct: wasm32 = 8B per entry, wasm64 = 16B per entry + const pw = this.getPtrWidth(channel.pid); + const iovEntrySize = pw === 8 ? 16 : 8; + + // Read iov entries from process memory + interface IovEntry { base: number; len: number } + const entries: IovEntry[] = []; + let totalData = 0; + for (let i = 0; i < iovcnt; i++) { + let base: number, len: number; + if (pw === 8) { + base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); + len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); + } else { + base = processView.getUint32(iovPtr + i * iovEntrySize, true); + len = processView.getUint32(iovPtr + i * iovEntrySize + 4, true); + } + entries.push({ base, len }); + totalData += len; } - // Call kernel with SYS_POLL: (fds_ptr, nfds, timeout_ms=0) - // Always use timeout=0 — we manage blocking/retry on the host side - kernelView.setUint32(CH_SYSCALL, SYS_POLL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(nfds), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(0), true); // timeout=0 for non-blocking poll - for (let i = 3; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); - } + // Max data that fits in scratch: CH_DATA_SIZE minus space for one iov entry (8 bytes) + const maxDataPerCall = CH_DATA_SIZE - 8; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + if (totalData <= maxDataPerCall && iovcnt <= Math.floor(CH_DATA_SIZE / 8)) { + // Fast path: everything fits in one kernel call + const iovSize = iovcnt * 8; + let dataOff = iovSize; + const kernelEntries: { base: number; kernelBase: number; len: number }[] = []; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + for (let i = 0; i < iovcnt; i++) { + const kernelBase = dataStart + dataOff; + kernelEntries.push({ base: entries[i].base, kernelBase, len: entries[i].len }); - // Handle signal delivery - this.dequeueSignalForDelivery(channel); + if (entries[i].len > 0) { + kernelMem.fill(0, kernelBase, kernelBase + entries[i].len); + } - // If poll returned error (not EAGAIN), propagate it - if (retVal < 0 && errVal !== EAGAIN) { - this.completeChannelRaw(channel, retVal, errVal); - this.relistenChannel(channel); - return; - } + const iovAddr = dataStart + i * 8; + new DataView(kernelMem.buffer).setUint32(iovAddr, kernelBase, true); + new DataView(kernelMem.buffer).setUint32(iovAddr + 4, entries[i].len, true); - // Count ready events and map back to epoll_event format - let readyCount = 0; - if (retVal > 0) { - const processView = new DataView(channel.memory.buffer); - for (let i = 0; i < nfds && readyCount < maxevents; i++) { - const off = dataStart + i * 8; - const revents = new DataView(this.kernelMemory!.buffer).getInt16(off + 6, true); - if (revents !== 0) { - // Map poll revents back to epoll events - let epEvents = 0; - if (revents & POLLIN) epEvents |= EPOLLIN; - if (revents & POLLOUT) epEvents |= EPOLLOUT; - if (revents & POLLERR) epEvents |= EPOLLERR; - if (revents & POLLHUP) epEvents |= EPOLLHUP; + dataOff += entries[i].len; + dataOff = (dataOff + 3) & ~3; + } - // Write epoll_event to process memory: { events: u32, data: u64 } = 12 bytes - const evOff = eventsPtr + readyCount * 12; - processView.setUint32(evOff, epEvents, true); - processView.setBigUint64(evOff + 4, interests[i].data, true); - readyCount++; - } + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(iovcnt), true); + if (syscallNr === SYS_PREADV) { + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(origArgs[3]), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(origArgs[4]), true); } - } - // If we got events, return them - if (readyCount > 0) { - this.completeChannelRaw(channel, readyCount, 0); - this.relistenChannel(channel); - return; - } + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } - // No events ready — handle timeout - if (timeoutMs === 0) { - // Non-blocking: return 0 events - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); - return; - } + if (this.finishSignalTermination(channel)) return; - // Blocking: retry via setTimeout to avoid starving other processes. - // Pipe-based wakeup (via wakeAllBlockedRetries) provides instant wakeup - // when data arrives; setTimeout is only a fallback. - const { pipeIndices, acceptIndices } = this.resolveEpollReadinessIndices(channel.pid); + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - const retryFn = () => { - this.pendingPollRetries.delete(channel.channelOffset); - if (this.processes.has(channel.pid)) { - this.handleEpollPwait(channel, syscallNr, origArgs); + if (retVal === -1 && errVal === EAGAIN) { + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; } - }; - const timer = setTimeout(retryFn, 10); - this.pendingPollRetries.set(channel.channelOffset, { - timer, - channel, - pipeIndices, - acceptIndices, - }); - } - - // ---- Network interface ioctl host-side handlers ---- - // The kernel has a single virtual network interface ("eth0") with a random - // MAC address generated per kernel instance. - /** - * Handle SIOCGIFCONF: enumerate network interfaces. - * struct ifconf { int ifc_len; union { char *ifc_buf; struct ifreq *ifc_req; }; } - * The ifc_buf pointer is in process memory, so the kernel can't write to it - * directly — we handle the entire ioctl on the host side. - */ - private handleIoctlIfconf(channel: ChannelInfo, origArgs: number[]): void { - const processView = new DataView(channel.memory.buffer); - const processMem = new Uint8Array(channel.memory.buffer); - const pw = this.getPtrWidth(channel.pid); + if (retVal > 0) { + let remaining = retVal; + for (const entry of kernelEntries) { + if (remaining <= 0) break; + const copyLen = Math.min(entry.len, remaining); + processMem.set( + kernelMem.subarray(entry.kernelBase, entry.kernelBase + copyLen), + entry.base, + ); + remaining -= copyLen; + } + } - // Read struct ifconf from process memory at arg[2] - // struct ifconf { int ifc_len; union { char *ifc_buf; struct ifreq *ifc_req; }; } - // wasm32: ifc_len at +0 (4B), ifc_buf at +4 (4B) — total 8 bytes - // wasm64: ifc_len at +0 (4B), [4B padding], ifc_buf at +8 (8B) — total 16 bytes - const ifconfPtr = origArgs[2]; - const ifcLen = processView.getInt32(ifconfPtr, true); - let ifcBuf: number; - if (pw === 8) { - ifcBuf = Number(processView.getBigUint64(ifconfPtr + 8, true)); + this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); } else { - ifcBuf = processView.getUint32(ifconfPtr + 4, true); - } + // Slow path: total data exceeds scratch buffer. Issue one SYS_READ per iov entry, + // chunked to fit in CH_DATA_SIZE. Use pread to maintain file offset for preadv. + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const isPreadv = syscallNr === SYS_PREADV; + let fileOffset = isPreadv + ? (origArgs[3] | 0) + (origArgs[4] | 0) * 0x100000000 + : 0; + let totalRead = 0; + let lastErr = 0; + let gotEagain = false; - // sizeof(struct ifreq) = 32 (16 name + 16 sockaddr union, no pointers — same on both) - const SIZEOF_IFREQ = 32; + for (const entry of entries) { + if (entry.len === 0) continue; + let entryRead = 0; - if (ifcLen >= SIZEOF_IFREQ && ifcBuf !== 0) { - // Write one ifreq entry for "eth0" into process memory at ifc_buf - const nameBytes = new TextEncoder().encode("eth0"); - processMem.set(nameBytes, ifcBuf); - processMem.fill(0, ifcBuf + nameBytes.length, ifcBuf + 16); // pad ifr_name + while (entryRead < entry.len) { + const chunkLen = Math.min(entry.len - entryRead, maxDataPerCall); + const kernelBuf = dataStart + 8; // single iov entry at dataStart, data after - // ifr_addr: AF_INET (2) with 127.0.0.1 — just needs to be a valid sockaddr - processMem.fill(0, ifcBuf + 16, ifcBuf + SIZEOF_IFREQ); - processView.setUint16(ifcBuf + 16, 2, true); // AF_INET - processMem[ifcBuf + 20] = 127; // sin_addr = 127.0.0.1 - processMem[ifcBuf + 21] = 0; - processMem[ifcBuf + 22] = 0; - processMem[ifcBuf + 23] = 1; + // Set up single iov entry + new DataView(kernelMem.buffer).setUint32(dataStart, kernelBuf, true); + new DataView(kernelMem.buffer).setUint32(dataStart + 4, chunkLen, true); + kernelMem.fill(0, kernelBuf, kernelBuf + chunkLen); - // Update ifc_len to actual bytes written - processView.setInt32(ifconfPtr, SIZEOF_IFREQ, true); - } else { - // No space or null buffer - processView.setInt32(ifconfPtr, 0, true); - } + if (isPreadv) { + // Use preadv with 1 iov + kernelView.setUint32(CH_SYSCALL, SYS_PREADV, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xFFFFFFFF), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000)), true); + } else { + // Use readv with 1 iov + kernelView.setUint32(CH_SYSCALL, SYS_READV, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); + } - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); - } + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } - /** - * Handle SIOCGIFHWADDR: get hardware (MAC) address for an interface. - * struct ifreq at arg[2]: ifr_name[16] + ifr_hwaddr (struct sockaddr, 16 bytes) - * Returns the virtual MAC in ifr_hwaddr.sa_data[0..5]. - */ - private handleIoctlIfhwaddr(channel: ChannelInfo, origArgs: number[]): void { - const processView = new DataView(channel.memory.buffer); - const processMem = new Uint8Array(channel.memory.buffer); - const ifreqPtr = origArgs[2]; + if (this.finishSignalTermination(channel)) return; - // Write ifr_hwaddr at offset 16 from ifreq start: - // sa_family = ARPHRD_ETHER (1) - // sa_data[0..5] = MAC address - processMem.fill(0, ifreqPtr + 16, ifreqPtr + 32); // clear sockaddr - processView.setUint16(ifreqPtr + 16, 1, true); // ARPHRD_ETHER - processMem.set(this.virtualMacAddress, ifreqPtr + 18); // MAC address + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); - } + if (retVal === -1) { + if (errVal === EAGAIN && totalRead === 0) { + gotEagain = true; + break; + } + lastErr = errVal; + break; + } - /** - * Handle SIOCGIFADDR: get interface address. - * struct ifreq at arg[2]: ifr_name[16] + ifr_addr (struct sockaddr, 16 bytes) - * Returns 127.0.0.1 for the virtual interface. - */ - private handleIoctlIfaddr(channel: ChannelInfo, origArgs: number[]): void { - const processView = new DataView(channel.memory.buffer); - const processMem = new Uint8Array(channel.memory.buffer); - const ifreqPtr = origArgs[2]; + if (retVal === 0) break; // EOF + + // Copy data to process memory + processMem.set( + kernelMem.subarray(kernelBuf, kernelBuf + retVal), + entry.base + entryRead, + ); + + entryRead += retVal; + totalRead += retVal; + if (isPreadv) fileOffset += retVal; + + if (retVal < chunkLen) break; // short read + } - // Write ifr_addr at offset 16: AF_INET + 127.0.0.1 - processMem.fill(0, ifreqPtr + 16, ifreqPtr + 32); - processView.setUint16(ifreqPtr + 16, 2, true); // AF_INET - processMem[ifreqPtr + 20] = 127; // sin_addr = 127.0.0.1 - processMem[ifreqPtr + 21] = 0; - processMem[ifreqPtr + 22] = 0; - processMem[ifreqPtr + 23] = 1; + if (gotEagain || lastErr) break; + } + + if (gotEagain) { + this.handleBlockingRetry(channel, syscallNr, origArgs); + return; + } - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + const finalRet = totalRead > 0 ? totalRead : (lastErr ? -1 : 0); + const finalErr = totalRead > 0 ? 0 : lastErr; + this.completeChannel(channel, syscallNr, origArgs, undefined, finalRet, finalErr); + } } - private handleWritev(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + /** + * Handle sendmsg: decompose msghdr from process memory, flatten data + addr + * into kernel scratch, call kernel_sendmsg which dispatches to sendto/send. + */ + private handleSendmsg(channel: ChannelInfo, origArgs: number[]): void { const fd = origArgs[0]; - const iovPtr = origArgs[1]; - const iovcnt = origArgs[2]; + const msgPtr = origArgs[1]; + const flags = origArgs[2]; const processMem = new Uint8Array(channel.memory.buffer); const processView = new DataView(channel.memory.buffer); const kernelMem = this.getKernelMem(); const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); const dataStart = this.scratchOffset + CH_DATA; - - // iovec struct: { void* iov_base, size_t iov_len } - // wasm32: 8 bytes per entry (4+4), wasm64: 16 bytes per entry (8+8) const pw = this.getPtrWidth(channel.pid); - const iovEntrySize = pw === 8 ? 16 : 8; - // Read iov entries from process memory - interface IovEntry { base: number; len: number } - const entries: IovEntry[] = []; - let totalData = 0; - for (let i = 0; i < iovcnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); - } else { - base = processView.getUint32(iovPtr + i * iovEntrySize, true); - len = processView.getUint32(iovPtr + i * iovEntrySize + 4, true); - } - entries.push({ base, len }); - totalData += len; + // Parse msghdr from process memory (ptrWidth-aware). + // wasm32 layout (28B): name(4), namelen(4), iov(4), iovlen(4), control(4), controllen(4), flags(4) + // wasm64 layout (48B): name(8), namelen(4), pad(4), iov(8), iovlen(4), pad(4), control(8), controllen(4), flags(4) + let namePtr: number, nameLen: number, iovPtr: number, iovCnt: number; + let controlPtr: number, controlLen: number; + if (pw === 8) { + namePtr = Number(processView.getBigUint64(msgPtr, true)); + nameLen = processView.getUint32(msgPtr + 8, true); + iovPtr = Number(processView.getBigUint64(msgPtr + 16, true)); + iovCnt = processView.getUint32(msgPtr + 24, true); + controlPtr = Number(processView.getBigUint64(msgPtr + 32, true)); + controlLen = processView.getUint32(msgPtr + 40, true); + } else { + namePtr = processView.getUint32(msgPtr, true); + nameLen = processView.getUint32(msgPtr + 4, true); + iovPtr = processView.getUint32(msgPtr + 8, true); + iovCnt = processView.getUint32(msgPtr + 12, true); + controlPtr = processView.getUint32(msgPtr + 16, true); + controlLen = processView.getUint32(msgPtr + 20, true); } - // Max data that fits in scratch: CH_DATA_SIZE minus space for iov entries - const iovSize = iovcnt * 8; - const maxDataPerCall = CH_DATA_SIZE - iovSize; + // Build kernel-side msghdr in wasm32 (28B) format (kernel uses explicit u32 parsing) + const kMsgPtr = dataStart; + const kv = new DataView(kernelMem.buffer); + kv.setUint32(kMsgPtr, namePtr, true); + kv.setUint32(kMsgPtr + 4, nameLen, true); + kv.setUint32(kMsgPtr + 8, iovPtr, true); // will be updated below + kv.setUint32(kMsgPtr + 12, iovCnt, true); + kv.setUint32(kMsgPtr + 16, controlPtr, true); // will be updated below + kv.setUint32(kMsgPtr + 20, controlLen, true); + kv.setUint32(kMsgPtr + 24, 0, true); // msg_flags - if (totalData <= maxDataPerCall) { - // Fast path: all data fits in one kernel call - let dataOff = iovSize; + let dataOff = 28; // after kernel-format msghdr - for (let i = 0; i < iovcnt; i++) { - const kernelBase = dataStart + dataOff; + // Copy msg_name to kernel scratch + if (namePtr !== 0 && nameLen > 0 && dataOff + nameLen <= CH_DATA_SIZE) { + const kNamePtr = dataStart + dataOff; + kernelMem.set(processMem.subarray(namePtr, namePtr + nameLen), kNamePtr); + kv.setUint32(kMsgPtr, kNamePtr, true); // update msg_name ptr + dataOff += nameLen; + dataOff = (dataOff + 3) & ~3; + } - if (entries[i].len > 0) { - kernelMem.set(processMem.subarray(entries[i].base, entries[i].base + entries[i].len), kernelBase); - } + // Copy msg_control (ancillary data, e.g. SCM_RIGHTS) to kernel scratch + if (controlPtr !== 0 && controlLen > 0 && dataOff + controlLen <= CH_DATA_SIZE) { + const kCtrlPtr = dataStart + dataOff; + kernelMem.set(processMem.subarray(controlPtr, controlPtr + controlLen), kCtrlPtr); + kv.setUint32(kMsgPtr + 16, kCtrlPtr, true); // update msg_control ptr + dataOff += controlLen; + dataOff = (dataOff + 3) & ~3; + } - const iovAddr = dataStart + i * 8; - new DataView(kernelMem.buffer).setUint32(iovAddr, kernelBase, true); - new DataView(kernelMem.buffer).setUint32(iovAddr + 4, entries[i].len, true); + // Copy iov array and iov data to kernel scratch + const iovEntrySize = pw === 8 ? 16 : 8; + if (iovCnt > 0 && iovPtr !== 0) { + const kIovSize = iovCnt * 8; // kernel-side iov is always 8 bytes per entry (u32 base + u32 len) + const kIovPtr = dataStart + dataOff; + dataOff += kIovSize; + dataOff = (dataOff + 3) & ~3; - dataOff += entries[i].len; - dataOff = (dataOff + 3) & ~3; // align - } + kv.setUint32(kMsgPtr + 8, kIovPtr, true); // update msg_iov ptr - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(iovcnt), true); - if (syscallNr === SYS_PWRITEV) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(origArgs[3]), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(origArgs[4]), true); - } + // Copy each iov buffer data + for (let i = 0; i < iovCnt; i++) { + let base: number, len: number; + if (pw === 8) { + base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); + len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); + } else { + base = processView.getUint32(iovPtr + i * 8, true); + len = processView.getUint32(iovPtr + i * 8 + 4, true); + } + // Write kernel-format iov entry (always u32 base + u32 len) + kv.setUint32(kIovPtr + i * 8, 0, true); // will be updated if data copied + kv.setUint32(kIovPtr + i * 8 + 4, len, true); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + if (len > 0 && dataOff + len <= CH_DATA_SIZE) { + const kBufPtr = dataStart + dataOff; + kernelMem.set(processMem.subarray(base, base + len), kBufPtr); + kv.setUint32(kIovPtr + i * 8, kBufPtr, true); + dataOff += len; + dataOff = (dataOff + 3) & ~3; + } } + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + // Call kernel + kernelView.setUint32(CH_SYSCALL, SYS_SENDMSG, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(kMsgPtr), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flags), true); - if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; - } + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); - } else { - // Slow path: total data exceeds scratch buffer. Issue individual SYS_WRITEV - // calls with one iov entry each, chunked to fit in CH_DATA_SIZE. - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const isPwritev = syscallNr === SYS_PWRITEV; - let fileOffset = isPwritev - ? (origArgs[3] | 0) + (origArgs[4] | 0) * 0x100000000 - : 0; - let totalWritten = 0; - let gotEagain = false; - const maxChunk = CH_DATA_SIZE - 8; // space for 1 iov entry (8B) + data + if (this.finishSignalTermination(channel)) return; - for (const entry of entries) { - if (entry.len === 0) continue; - let entryWritten = 0; + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - while (entryWritten < entry.len) { - const chunkLen = Math.min(entry.len - entryWritten, maxChunk); - const kernelBuf = dataStart + 8; // single iov entry at dataStart, data after + if (retVal === -1 && errVal === EAGAIN) { + this.handleBlockingRetry(channel, SYS_SENDMSG, origArgs); + return; + } - // Copy data from process to kernel scratch - kernelMem.set( - processMem.subarray(entry.base + entryWritten, entry.base + entryWritten + chunkLen), - kernelBuf, - ); + this.completeChannel(channel, SYS_SENDMSG, origArgs, undefined, retVal, errVal); + } - // Set up single iov entry - new DataView(kernelMem.buffer).setUint32(dataStart, kernelBuf, true); - new DataView(kernelMem.buffer).setUint32(dataStart + 4, chunkLen, true); + /** + * Handle recvmsg: decompose msghdr from process memory, set up buffers in + * kernel scratch, call kernel_recvmsg, copy results back. + */ + private handleRecvmsg(channel: ChannelInfo, origArgs: number[]): void { + const fd = origArgs[0]; + const msgPtr = origArgs[1]; + const flags = origArgs[2]; - if (isPwritev) { - kernelView.setUint32(CH_SYSCALL, SYS_PWRITEV, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xFFFFFFFF), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000)), true); - } else { - kernelView.setUint32(CH_SYSCALL, SYS_WRITEV, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - } + const processMem = new Uint8Array(channel.memory.buffer); + const processView = new DataView(channel.memory.buffer); + const kernelMem = this.getKernelMem(); + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const dataStart = this.scratchOffset + CH_DATA; + const pw = this.getPtrWidth(channel.pid); + + // Parse msghdr from process memory (ptrWidth-aware) + let namePtr: number, nameLen: number, iovPtr: number, iovCnt: number; + let controlPtr: number, controlLen: number; + if (pw === 8) { + namePtr = Number(processView.getBigUint64(msgPtr, true)); + nameLen = processView.getUint32(msgPtr + 8, true); + iovPtr = Number(processView.getBigUint64(msgPtr + 16, true)); + iovCnt = processView.getUint32(msgPtr + 24, true); + controlPtr = Number(processView.getBigUint64(msgPtr + 32, true)); + controlLen = processView.getUint32(msgPtr + 40, true); + } else { + namePtr = processView.getUint32(msgPtr, true); + nameLen = processView.getUint32(msgPtr + 4, true); + iovPtr = processView.getUint32(msgPtr + 8, true); + iovCnt = processView.getUint32(msgPtr + 12, true); + controlPtr = processView.getUint32(msgPtr + 16, true); + controlLen = processView.getUint32(msgPtr + 20, true); + } + + // Build kernel-side msghdr in wasm32 (28B) format + const kMsgPtr = dataStart; + const kv = new DataView(kernelMem.buffer); + kv.setUint32(kMsgPtr, namePtr, true); + kv.setUint32(kMsgPtr + 4, nameLen, true); + kv.setUint32(kMsgPtr + 8, iovPtr, true); + kv.setUint32(kMsgPtr + 12, iovCnt, true); + kv.setUint32(kMsgPtr + 16, controlPtr, true); + kv.setUint32(kMsgPtr + 20, controlLen, true); + kv.setUint32(kMsgPtr + 24, 0, true); - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + let dataOff = 28; - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + // Set up msg_name output buffer + let kNamePtr = 0; + if (namePtr !== 0 && nameLen > 0 && dataOff + nameLen <= CH_DATA_SIZE) { + kNamePtr = dataStart + dataOff; + kernelMem.fill(0, kNamePtr, kNamePtr + nameLen); + kv.setUint32(kMsgPtr, kNamePtr, true); + dataOff += nameLen; + dataOff = (dataOff + 3) & ~3; + } - if (retVal === -1) { - if (errVal === EAGAIN && totalWritten === 0) { - gotEagain = true; - } - break; - } + // Set up msg_control output buffer for ancillary data (SCM_RIGHTS) + let kCtrlPtr = 0; + if (controlPtr !== 0 && controlLen > 0 && dataOff + controlLen <= CH_DATA_SIZE) { + kCtrlPtr = dataStart + dataOff; + kernelMem.fill(0, kCtrlPtr, kCtrlPtr + controlLen); + kv.setUint32(kMsgPtr + 16, kCtrlPtr, true); + dataOff += controlLen; + dataOff = (dataOff + 3) & ~3; + } - entryWritten += retVal; - totalWritten += retVal; - if (isPwritev) fileOffset += retVal; + // Set up iov array and output buffers + interface IovEntry { base: number; len: number; kernelBase: number } + const entries: IovEntry[] = []; + const iovEntrySize = pw === 8 ? 16 : 8; - if (retVal < chunkLen) break; // short write (e.g. pipe full) - } + if (iovCnt > 0 && iovPtr !== 0) { + const kIovSize = iovCnt * 8; // kernel-side iov always 8B per entry + const kIovPtr = dataStart + dataOff; + dataOff += kIovSize; + dataOff = (dataOff + 3) & ~3; - if (gotEagain || entryWritten < entry.len) break; - } + kv.setUint32(kMsgPtr + 8, kIovPtr, true); - if (gotEagain) { - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; + for (let i = 0; i < iovCnt; i++) { + let base: number, len: number; + if (pw === 8) { + base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); + len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); + } else { + base = processView.getUint32(iovPtr + i * 8, true); + len = processView.getUint32(iovPtr + i * 8 + 4, true); + } + if (len > 0 && dataOff + len <= CH_DATA_SIZE) { + const kBufPtr = dataStart + dataOff; + kernelMem.fill(0, kBufPtr, kBufPtr + len); + kv.setUint32(kIovPtr + i * 8, kBufPtr, true); + kv.setUint32(kIovPtr + i * 8 + 4, len, true); + entries.push({ base, len, kernelBase: kBufPtr }); + dataOff += len; + dataOff = (dataOff + 3) & ~3; + } else { + kv.setUint32(kIovPtr + i * 8, 0, true); + kv.setUint32(kIovPtr + i * 8 + 4, len, true); + } } - - this.completeChannelRaw(channel, totalWritten, 0); - this.relistenChannel(channel); } - } - /** - * Handle large write/pwrite where the data exceeds CH_DATA_SIZE. - * Loops through CH_DATA_SIZE chunks, issuing individual kernel calls. - */ - private handleLargeWrite(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const fd = origArgs[0]; - const bufPtr = origArgs[1]; - const totalLen = origArgs[2]; - const isPwrite = syscallNr === SYS_PWRITE; - // pwrite offset is a single i64 arg (arg index 3) - let fileOffset = isPwrite ? origArgs[3] : 0; + // Call kernel + kernelView.setUint32(CH_SYSCALL, SYS_RECVMSG, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(kMsgPtr), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flags), true); - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; + } - let totalWritten = 0; + if (this.finishSignalTermination(channel)) return; - while (totalWritten < totalLen) { - const chunkLen = Math.min(totalLen - totalWritten, CH_DATA_SIZE); + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - // Copy chunk from process memory to kernel scratch - kernelMem.set( - processMem.subarray(bufPtr + totalWritten, bufPtr + totalWritten + chunkLen), - dataStart, - ); + if (retVal === -1 && errVal === EAGAIN) { + this.handleBlockingRetry(channel, SYS_RECVMSG, origArgs); + return; + } - // Set up syscall in kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkLen), true); - if (isPwrite) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); + // Copy received data back to process memory + if (retVal > 0) { + let remaining = retVal; + for (const entry of entries) { + if (remaining <= 0) break; + const copyLen = Math.min(entry.len, remaining); + processMem.set( + kernelMem.subarray(entry.kernelBase, entry.kernelBase + copyLen), + entry.base, + ); + remaining -= copyLen; } + } - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch (err) { - console.error(`[handleLargeWrite] kernel threw for pid=${channel.pid}:`, err); - if (totalWritten > 0) { - this.completeChannelRaw(channel, totalWritten, 0); - } else { - this.completeChannelRaw(channel, -5, 5); // -EIO - } - this.relistenChannel(channel); - return; - } finally { - this.currentHandlePid = 0; + // Copy msg_name (source address) back to process memory + if (kNamePtr !== 0 && namePtr !== 0 && nameLen > 0) { + processMem.set(kernelMem.subarray(kNamePtr, kNamePtr + nameLen), namePtr); + } + + // Copy msg_control (ancillary data) back to process memory + if (kCtrlPtr !== 0 && controlPtr !== 0) { + const actualControlLen = kv.getUint32(kMsgPtr + 20, true); + if (actualControlLen > 0 && actualControlLen <= controlLen) { + processMem.set( + kernelMem.subarray(kCtrlPtr, kCtrlPtr + actualControlLen), + controlPtr, + ); } + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + // Copy updated msghdr fields back to process memory (ptrWidth-aware) + const kNamelenVal = kv.getUint32(kMsgPtr + 4, true); + const kControllenVal = kv.getUint32(kMsgPtr + 20, true); + const kMsgflags = kv.getUint32(kMsgPtr + 24, true); + if (pw === 8) { + processView.setUint32(msgPtr + 8, kNamelenVal, true); // msg_namelen + processView.setUint32(msgPtr + 40, kControllenVal, true); // msg_controllen + processView.setUint32(msgPtr + 44, kMsgflags, true); // msg_flags + } else { + processView.setUint32(msgPtr + 4, kNamelenVal, true); // msg_namelen + processView.setUint32(msgPtr + 20, kControllenVal, true); // msg_controllen + processView.setUint32(msgPtr + 24, kMsgflags, true); // msg_flags + } - if (retVal === -1 && errVal === EAGAIN) { - if (totalWritten > 0) { - this.completeChannelRaw(channel, totalWritten, 0); - this.relistenChannel(channel); - return; - } - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; + this.completeChannel(channel, SYS_RECVMSG, origArgs, undefined, retVal, errVal); + } + + // ----------------------------------------------------------------------- + // Fork/exec/clone/exit handling + // ----------------------------------------------------------------------- + + /** + * Handle SYS_FORK/SYS_VFORK: clone the Process in the kernel's ProcessTable, + * then call the onFork callback to spawn the child Worker. + */ + private handleFork(channel: ChannelInfo, _origArgs: number[]): void { + if (!this.callbacks.onFork) { + // No fork handler — return -ENOSYS + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 38); + return; + } + + const parentPid = channel.pid; + // Publish the parent's private views before creating any kernel child. + // A backing refresh can fail; keeping this fallible work ahead of + // kernel_fork_process avoids leaking a committed child/zombie or reserved + // pthread slot when fork must report EIO. + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + if (!this.syncSysvShmMappingsFromProcess(channel, { force: true })) { + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, EIO); + return; + } + + // The host knows about live workers, while the kernel also owns zombie and + // limbo records until they are reaped. Retry candidates rejected with + // EEXIST so fork cannot collide with a kernel-owned pid that has no live + // host registration. + const kernelForkProcess = this.kernelInstance!.exports.kernel_fork_process as + (parentPid: number, childPid: number) => number; + let childPid = 0; + let forkResult = -EEXIST; + for (let attempts = 0; attempts < 4096; attempts++) { + while (this.processes.has(this.nextChildPid)) { + this.nextChildPid++; } + childPid = this.nextChildPid++; + forkResult = kernelForkProcess(parentPid, childPid); + if (forkResult === 0 || -forkResult !== EEXIST) break; + } + if (forkResult < 0) { + // Fork failed in kernel (e.g., ESRCH, ENOMEM) + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, (-forkResult) >>> 0); + return; + } - if (errVal !== 0 || retVal <= 0) { - if (totalWritten > 0) { - this.completeChannelRaw(channel, totalWritten, 0); - } else { - this.completeChannelRaw(channel, retVal, errVal); + // Clear fork_child flag immediately. With wpk_fork instrumentation, the + // child resumes from the fork point and never checks this flag. Without + // clearing it, a nested fork() from the child would hit the isForkChild + // check above and return 0 instead of creating a grandchild. + const clearForkChild = this.kernelInstance!.exports.kernel_clear_fork_child as + ((pid: number) => number) | undefined; + if (clearForkChild) clearForkChild(childPid); + + // Clear the child's blocked signal mask. With wpk_fork instrumentation, + // musl's __restore_sigs after fork() runs in the child, but we clear it + // here too for safety. Without fork instrumentation, the child re-executes + // _start and never gets __restore_sigs. + const resetSignalMask = this.kernelInstance!.exports.kernel_reset_signal_mask as + ((pid: number) => number) | undefined; + if (resetSignalMask) resetSignalMask(childPid); + + // If the syscall arrived on a thread channel (registered via clone() + // with tid > 0), the wpk_fork save buffer is at THIS channel's offset + // and the unwind frames are rooted in the pthread entry function, not + // _start. Pass that context to onFork so the child Worker can rewind + // correctly. + const threadKey = `${parentPid}:${channel.channelOffset}`; + const threadCtx = this.threadForkContexts.get(threadKey); + const callerSlotStart = + channel.channelOffset - PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE * WASM_PAGE_SIZE; + const callerSlotLen = PROCESS_MEMORY_PAGES_PER_THREAD_SLOT * WASM_PAGE_SIZE; + const threadFork: ForkFromThreadContext | undefined = threadCtx + ? { + fnPtr: threadCtx.fnPtr, + argPtr: threadCtx.argPtr, + forkBufAddr: channel.channelOffset - FORK_BUF_SIZE, + slotStart: callerSlotStart, + slotLen: callerSlotLen, } - this.relistenChannel(channel); + : undefined; + + if (threadFork) { + try { + this.reserveHostRegionAt(childPid, threadFork.slotStart, threadFork.slotLen); + } catch (err) { + this.removeFromKernelProcessTable(childPid); + const message = err instanceof Error ? err.message : String(err); + console.error(`[kernel-worker] fork child slot reservation failed: ${message}`); + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); return; } + } - totalWritten += retVal; - if (isPwrite) fileOffset += retVal; + // The kernel child is real before its host Worker launches. Install its + // host-only fd mirrors synchronously so a sibling exec cannot remove the + // parent's last listener and close the shared backend during onFork's + // async worker setup. pickListenerTarget still ignores the child until + // onFork registers its process memory. + const removeProcess = this.kernelInstance!.exports.kernel_remove_process as + (pid: number) => number; + const rollbackFork = (err?: unknown) => { + if (err !== undefined) { + console.error(`[kernel-worker] fork worker launch failed: ${String(err)}`); + } + try { this.rollbackChildHostRegistration(childPid); } catch { /* best-effort */ } + try { removeProcess(childPid); } catch { /* best-effort */ } + if (this.isAsyncChannelProcessActive(channel)) { + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); + } + }; - // Short write from kernel — return what we have - if (retVal < chunkLen) break; + let launch: Promise; + try { + this.inheritHostFdMirrors(parentPid, childPid); + launch = Promise.resolve( + this.callbacks.onFork(parentPid, childPid, channel.memory, threadFork), + ); + } catch (err) { + rollbackFork(err); + return; } - this.dequeueSignalForDelivery(channel); - this.completeChannelRaw(channel, totalWritten, 0); - this.relistenChannel(channel); + // Call the async fork handler to spawn child Worker. + launch.then((_childChannelOffsets) => { + this.finalizePendingChildTermination(childPid); + + // A sibling may have committed exec while the child worker launched. + // The child is already real and still inherits host mirrors; only the + // discarded caller's channel completion must be suppressed. + if (!this.isAsyncChannelProcessActive(channel)) return; + + // Complete parent's channel with child PID + this.completeChannel(channel, SYS_FORK, _origArgs, undefined, childPid, 0); + }).catch(rollbackFork); } /** - * Handle large read/pread where the buffer exceeds CH_DATA_SIZE. - * Loops through CH_DATA_SIZE chunks, copying data back to process memory. + * Handle SYS_SPAWN: read the blob and `path` from caller memory, copy + * the blob to kernel scratch, ask the kernel to allocate a child pid + + * build the child Process descriptor, then call `onSpawn` to launch a + * fresh worker for that pid. + * + * Channel arg layout (per docs/plans/2026-05-04-non-forking-posix-spawn-design.md): + * arg0 = path_ptr (caller memory; PATH-resolved) + * arg1 = path_len + * arg2 = blob_ptr (caller memory) + * arg3 = blob_len + * arg4 = pid_out_ptr (caller writes child pid here on success) + * arg5 = 0 (reserved) + * + * Returns 0 on success / -errno on failure via the channel; the child + * pid is delivered through `pid_out_ptr` rather than the return value + * so callers can distinguish "kernel error" (negative) from "got a + * child" (zero, then read pid_out). + * + * If `onSpawn` returns non-zero or rejects, the kernel-side child + * descriptor is rolled back via `kernel_remove_process` so the spawn + * attempt leaves no trace. */ - private handleLargeRead(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const fd = origArgs[0]; - const bufPtr = origArgs[1]; - const totalLen = origArgs[2]; - const isPread = syscallNr === SYS_PREAD; - let fileOffset = isPread ? origArgs[3] : 0; + private handleSpawn(channel: ChannelInfo, origArgs: number[]): void { + const parentPid = channel.pid; + const pathPtr = origArgs[0]; + const pathLen = origArgs[1]; + const blobPtr = origArgs[2]; + const blobLen = origArgs[3]; + const pidOutPtr = origArgs[4]; - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; + if (!this.callbacks.onSpawn || !this.callbacks.onResolveSpawn) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 38); // ENOSYS + return; + } - let totalRead = 0; + // ── Read path + blob from caller memory ── + const processMem = new Uint8Array(channel.memory.buffer); + let path = ""; + if (pathPtr !== 0 && pathLen > 0) { + path = new TextDecoder().decode(processMem.slice(pathPtr, pathPtr + pathLen)); + // Strip trailing NUL if the user copied a C string with the terminator. + if (path.endsWith("\0")) path = path.slice(0, -1); + } + const rawPath = path; + if (path && !path.startsWith("/")) { + path = this.resolveExecPathAgainstCwd(parentPid, path); + } - while (totalRead < totalLen) { - const chunkLen = Math.min(totalLen - totalRead, CH_DATA_SIZE); + if (blobLen <= 0 || (blobPtr === 0 && blobLen > 0)) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL + return; + } + // .slice copies into a regular ArrayBuffer (TextDecoder rejects SAB views). + const blobBytes = processMem.slice(blobPtr, blobPtr + blobLen); - // Zero the scratch data area for the read output - kernelMem.fill(0, dataStart, dataStart + chunkLen); + // ── Decode argv + envp host-side ── + // The kernel parses the blob too, but onSpawn needs string[] for the + // worker launch path. We don't redo action/attr parsing here; the + // kernel is the authoritative parser for that surface. + let argv: string[]; + let envp: string[]; + try { + const decoded = decodeSpawnBlobStrings(blobBytes); + argv = decoded.argv; + envp = decoded.envp; + } catch (_e) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL + return; + } - // Set up syscall in kernel scratch - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkLen), true); - if (isPread) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); + // ── PRE-FLIGHT: resolve and compile BEFORE calling the kernel ── + // POSIX requires file_actions to run "exactly once." `posix_spawnp`'s + // PATH search emits one `posix_spawn` per candidate; if we let the + // kernel apply file_actions on each iteration, the side effects + // (e.g. `addopen(O_EXCL)`) accumulate and the second iteration sees + // its own state from the first. Resolve bytes via the host's + // side-effect-free preflight first; only call the kernel if the + // program actually exists and compiles. + const resolveSpawnProgram = async (): Promise => { + const resolved = await this.callbacks.onResolveSpawn!(path, argv); + if (resolved || rawPath === path || !rawPath || rawPath.startsWith("/")) { + return resolved; } - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch (err) { - console.error(`[handleLargeRead] kernel threw for pid=${channel.pid}:`, err); - if (totalRead > 0) { - this.completeChannelRaw(channel, totalRead, 0); - } else { - this.completeChannelRaw(channel, -5, 5); // -EIO - } - this.relistenChannel(channel); + // SYS_SPAWN is also used by posix_spawnp-style PATH probes. Those + // callers may hand us a relative executable name that exists only in + // the host execPrograms map, not in the kernel VFS at CWD/name. + // Keep the CWD-resolved path as the primary POSIX exec target, but + // fall back to the original token for host-side program maps. + return this.callbacks.onResolveSpawn!(rawPath, argv); + }; + + resolveSpawnProgram().then((resolved) => { + if (!this.isAsyncChannelProcessActive(channel)) return; + if (!resolved) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 2); // ENOENT return; - } finally { - this.currentHandlePid = 0; } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - - if (retVal === -1 && errVal === EAGAIN) { - if (totalRead > 0) { - this.completeChannelRaw(channel, totalRead, 0); - this.relistenChannel(channel); - return; - } - this.handleBlockingRetry(channel, syscallNr, origArgs); + if (isSpawnResolveError(resolved)) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, resolved.errno >>> 0); return; } + this.handleSpawnAfterResolve( + channel, origArgs, parentPid, pidOutPtr, blobBytes, blobLen, resolved, envp, + ); + }).catch((err) => { + if (!this.isAsyncChannelProcessActive(channel)) return; + console.error(`[kernel] spawn resolve error for parent ${parentPid}:`, err); + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 5); // EIO + }); + } - if (errVal !== 0 || retVal <= 0) { - if (totalRead > 0) { - this.completeChannelRaw(channel, totalRead, 0); - } else { - this.completeChannelRaw(channel, retVal, errVal); - } - this.relistenChannel(channel); - return; + /** + * Continuation of `handleSpawn` after `onResolveSpawn` has returned + * validated, compiled program. Now safe to ask the kernel to build the + * child (which will apply file_actions exactly once). + */ + private handleSpawnAfterResolve( + channel: ChannelInfo, + origArgs: number[], + parentPid: number, + pidOutPtr: number, + blobBytes: Uint8Array, + blobLen: number, + program: ResolvedSpawnProgram, + envp: string[], + ): void { + // ── Copy blob to kernel scratch ── + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + if (blobLen > kernelMem.byteLength - this.scratchOffset) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL + return; + } + kernelMem.set(blobBytes, this.scratchOffset); + + // ── Ask the kernel to build the child descriptor ── + const kernelSpawn = this.kernelInstance!.exports.kernel_spawn_process as + (parentPid: number, blobPtr: KernelPointer, blobLen: KernelPointer) => number; + const result = kernelSpawn( + parentPid, + this.toKernelPtr(this.scratchOffset), + this.toKernelPtr(blobLen), + ); + if (result < 0) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, (-result) >>> 0); + return; + } + const childPid = result >>> 0; + + // Bump host-side nextChildPid watermark so a subsequent fork() in the + // parent can't collide with the kernel's allocation. + if (childPid >= this.nextChildPid) this.nextChildPid = childPid + 1; + + const removeProcess = this.kernelInstance!.exports.kernel_remove_process as + (pid: number) => number; + const rollbackSpawn = (errno: number, err?: unknown) => { + if (err !== undefined) { + console.error(`[kernel] spawn error for parent ${parentPid}:`, err); + } + try { this.rollbackChildHostRegistration(childPid); } catch { /* best-effort */ } + try { removeProcess(childPid); } catch { /* best-effort */ } + if (this.isAsyncChannelProcessActive(channel)) { + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, errno); } + }; - // Copy read data from kernel scratch to process memory - processMem.set( - kernelMem.subarray(dataStart, dataStart + retVal), - bufPtr + totalRead, + // posix_spawn clones listener sockets after applying fd actions. Install + // those mirrors before async Worker launch so parent exec cannot close the + // shared backend. Epoll backing tables are not yet cloned by spawn_child, + // so only listener mirrors are inherited here. + let launch: Promise; + try { + this.inheritHostFdMirrors(parentPid, childPid, false); + launch = Promise.resolve( + this.callbacks.onSpawn!(parentPid, childPid, program, envp), ); + } catch (err) { + rollbackSpawn(5, err); + return; + } - totalRead += retVal; - if (isPread) fileOffset += retVal; + // ── Launch the worker async (with the precompiled program) ── + launch.then((rc) => { + if (rc < 0) { + rollbackSpawn((-rc) >>> 0); + return; + } + this.finalizePendingChildTermination(childPid); + if (!this.isAsyncChannelProcessActive(channel)) return; + // Write the child pid through pid_out_ptr in caller memory. + if (pidOutPtr !== 0) { + new DataView(channel.memory.buffer).setInt32(pidOutPtr, childPid, true); + } + this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, 0, 0); + }).catch((err) => { + rollbackSpawn(5, err); // EIO + }); + } - // Short read (EOF or partial) — return what we have - if (retVal < chunkLen) break; + /** + * Read a null-terminated string from process memory at the given pointer. + */ + private readCStringFromProcess(mem: Uint8Array, ptr: number, maxLen = 4096): string { + if (ptr === 0) return ""; + let len = 0; + while (ptr + len < mem.length && mem[ptr + len] !== 0 && len < maxLen) { + len++; } - - this.dequeueSignalForDelivery(channel); - this.completeChannelRaw(channel, totalRead, 0); - this.relistenChannel(channel); + // .slice() copies from SharedArrayBuffer into a regular ArrayBuffer + // because TextDecoder.decode() doesn't accept SharedArrayBuffer views. + return new TextDecoder().decode(mem.slice(ptr, ptr + len)); } /** - * Handle readv/preadv: set up iov array in kernel scratch, call - * kernel_handle_channel, then copy read data back to process memory. + * Read an exec pathname without allowing the generic C-string helper's + * bounded scan to turn an overlong or inaccessible pathname into a + * different, truncated path. */ - private handleReadv(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const fd = origArgs[0]; - const iovPtr = origArgs[1]; - const iovcnt = origArgs[2]; - - const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - - // iovec struct: wasm32 = 8B per entry, wasm64 = 16B per entry - const pw = this.getPtrWidth(channel.pid); - const iovEntrySize = pw === 8 ? 16 : 8; - - // Read iov entries from process memory - interface IovEntry { base: number; len: number } - const entries: IovEntry[] = []; - let totalData = 0; - for (let i = 0; i < iovcnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); - } else { - base = processView.getUint32(iovPtr + i * iovEntrySize, true); - len = processView.getUint32(iovPtr + i * iovEntrySize + 4, true); - } - entries.push({ base, len }); - totalData += len; + private readExecPathFromProcess( + mem: Uint8Array, + ptr: number, + ): { value: string } | { errno: number } { + if (!Number.isSafeInteger(ptr) || ptr <= 0 || ptr >= mem.byteLength) { + return { errno: EFAULT }; } - // Max data that fits in scratch: CH_DATA_SIZE minus space for one iov entry (8 bytes) - const maxDataPerCall = CH_DATA_SIZE - 8; - - if (totalData <= maxDataPerCall && iovcnt <= Math.floor(CH_DATA_SIZE / 8)) { - // Fast path: everything fits in one kernel call - const iovSize = iovcnt * 8; - let dataOff = iovSize; - const kernelEntries: { base: number; kernelBase: number; len: number }[] = []; - - for (let i = 0; i < iovcnt; i++) { - const kernelBase = dataStart + dataOff; - kernelEntries.push({ base: entries[i].base, kernelBase, len: entries[i].len }); - - if (entries[i].len > 0) { - kernelMem.fill(0, kernelBase, kernelBase + entries[i].len); - } + const available = mem.byteLength - ptr; + const scanLength = Math.min(available, EXEC_PATH_MAX_BYTES); + let byteLength = 0; + while (byteLength < scanLength && mem[ptr + byteLength] !== 0) { + byteLength++; + } + if (byteLength === scanLength) { + return { errno: available >= EXEC_PATH_MAX_BYTES ? ENAMETOOLONG : EFAULT }; + } - const iovAddr = dataStart + i * 8; - new DataView(kernelMem.buffer).setUint32(iovAddr, kernelBase, true); - new DataView(kernelMem.buffer).setUint32(iovAddr + 4, entries[i].len, true); + return { + // .slice() copies from SharedArrayBuffer for TextDecoder compatibility. + value: new TextDecoder().decode(mem.slice(ptr, ptr + byteLength)), + }; + } - dataOff += entries[i].len; - dataOff = (dataOff + 3) & ~3; + /** + * Read a null-terminated exec argv/envp pointer array without truncation. + * Each entry may occupy one bounded scratch transfer. The advertised + * ARG_MAX budget, including pointer entries, bounds the scan without an + * unrelated argument-count limit. + */ + private readStringArrayFromProcess( + mem: Uint8Array, + arrayPtr: number, + ptrWidth: 4 | 8 = 4, + ): { values: string[] } | { errno: number } { + if (arrayPtr === 0) return { values: [] }; + const values: string[] = []; + const view = new DataView(mem.buffer, mem.byteOffset, mem.byteLength); + // Reserve the list's terminating null pointer up front. Every non-null + // entry consumes at least ptrWidth + one NUL byte, so this byte budget also + // provides a finite loop bound for arrays containing empty strings. + let representedBytes = ptrWidth; + for (let i = 0; representedBytes <= EXEC_METADATA_MAX_BYTES; i++) { + const pointerOffset = arrayPtr + i * ptrWidth; + if (!Number.isSafeInteger(pointerOffset) || pointerOffset < 0 + || pointerOffset + ptrWidth > view.byteLength) { + return { errno: EFAULT }; } - - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(iovcnt), true); - if (syscallNr === SYS_PREADV) { - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(origArgs[3]), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(origArgs[4]), true); + let strPtr: number; + if (ptrWidth === 8) { + const rawPtr = view.getBigUint64(pointerOffset, true); + if (rawPtr > BigInt(Number.MAX_SAFE_INTEGER)) return { errno: EFAULT }; + strPtr = Number(rawPtr); + } else { + strPtr = view.getUint32(pointerOffset, true); } + if (strPtr === 0) return { values }; + if (strPtr < 0 || strPtr >= mem.byteLength) return { errno: EFAULT }; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + const scanLength = Math.min(mem.byteLength - strPtr, CH_DATA_SIZE + 1); + let byteLength = 0; + while (byteLength < scanLength && mem[strPtr + byteLength] !== 0) { + byteLength++; } - - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); - - if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, syscallNr, origArgs); - return; + if (byteLength === scanLength) { + return { errno: scanLength > CH_DATA_SIZE ? E2BIG : EFAULT }; } + if (byteLength > CH_DATA_SIZE) return { errno: E2BIG }; - if (retVal > 0) { - let remaining = retVal; - for (const entry of kernelEntries) { - if (remaining <= 0) break; - const copyLen = Math.min(entry.len, remaining); - processMem.set( - kernelMem.subarray(entry.kernelBase, entry.kernelBase + copyLen), - entry.base, - ); - remaining -= copyLen; - } + representedBytes += ptrWidth + byteLength + 1; + if (!Number.isSafeInteger(representedBytes) + || representedBytes > EXEC_METADATA_MAX_BYTES) { + return { errno: E2BIG }; } - this.completeChannel(channel, syscallNr, origArgs, undefined, retVal, errVal); - } else { - // Slow path: total data exceeds scratch buffer. Issue one SYS_READ per iov entry, - // chunked to fit in CH_DATA_SIZE. Use pread to maintain file offset for preadv. - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const isPreadv = syscallNr === SYS_PREADV; - let fileOffset = isPreadv - ? (origArgs[3] | 0) + (origArgs[4] | 0) * 0x100000000 - : 0; - let totalRead = 0; - let lastErr = 0; - let gotEagain = false; + // .slice() copies from SharedArrayBuffer for TextDecoder compatibility. + values.push(new TextDecoder().decode(mem.slice(strPtr, strPtr + byteLength))); + } + return { errno: E2BIG }; + } - for (const entry of entries) { - if (entry.len === 0) continue; - let entryRead = 0; + /** Complete a failed async exec only if the old image is still Running. */ + private finishFailedExec( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + errno: number, + ): void { + if (!this.isAsyncChannelProcessActive(channel)) return; + this.completeChannel(channel, syscallNr, origArgs, undefined, -1, errno); + } - while (entryRead < entry.len) { - const chunkLen = Math.min(entry.len - entryRead, maxDataPerCall); - const kernelBuf = dataStart + 8; // single iov entry at dataStart, data after + /** + * Handle SYS_EXECVE: read path, argv, and envp from process memory, + * then call the onExec callback to load the new program. + */ + private handleExec(channel: ChannelInfo, origArgs: number[]): void { + const processMem = new Uint8Array(channel.memory.buffer); - // Set up single iov entry - new DataView(kernelMem.buffer).setUint32(dataStart, kernelBuf, true); - new DataView(kernelMem.buffer).setUint32(dataStart + 4, chunkLen, true); - kernelMem.fill(0, kernelBuf, kernelBuf + chunkLen); + // Read path (arg 0), argv (arg 1), envp (arg 2) from process memory + const pw = this.getPtrWidth(channel.pid); + const pathResult = this.readExecPathFromProcess(processMem, origArgs[0]); + if ("errno" in pathResult) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, pathResult.errno); + return; + } + let path = pathResult.value; + const argvResult = this.readStringArrayFromProcess(processMem, origArgs[1], pw); + const envResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + if ("errno" in argvResult) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, argvResult.errno); + return; + } + if ("errno" in envResult) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, envResult.errno); + return; + } + const argv = argvResult.values; + const envp = envResult.values; - if (isPreadv) { - // Use preadv with 1 iov - kernelView.setUint32(CH_SYSCALL, SYS_PREADV, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xFFFFFFFF), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000)), true); - } else { - // Use readv with 1 iov - kernelView.setUint32(CH_SYSCALL, SYS_READV, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(1), true); - } + // Resolve relative exec paths against process CWD (not initial KERNEL_CWD). + // Critical for posix_spawn with chdir file actions where child CWD != parent CWD. + if (path && !path.startsWith("/")) { + path = this.resolveExecPathAgainstCwd(channel.pid, path); + } - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; - } + if (!this.callbacks.onExec) { + this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, 38); // ENOSYS + return; + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + // Call the async exec handler FIRST — onExec returns ENOENT early if the + // program doesn't exist, allowing posix_spawnp/execvpe PATH search to retry. + // kernel_exec_setup and prepareProcessForExec are deferred until after + // onExec confirms the program exists (returns 0). + const callerTid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? channel.pid; + this.callbacks.onExec(channel.pid, path, argv, envp, callerTid).then((result) => { + if (result < 0) { + // Exec failed (e.g. ENOENT) — process is still alive. + // Complete the channel so the calling process can handle the error + // (e.g., __execvpe tries the next PATH entry). + this.finishFailedExec(channel, SYS_EXECVE, origArgs, (-result) >>> 0); + } + // On success (result === 0), execve doesn't return — the Worker has been + // reinitialized with the new program via registerProcess. The old channel + // is dead (prepareProcessForExec removed it in onExec). + }).catch((err) => { + console.error(`[kernel] exec error for pid ${channel.pid}:`, err); + this.finishFailedExec(channel, SYS_EXECVE, origArgs, 5); // EIO + }); + } - if (retVal === -1) { - if (errVal === EAGAIN && totalRead === 0) { - gotEagain = true; - break; - } - lastErr = errVal; - break; - } + /** + * Resolve a relative exec path against the process's kernel CWD. + * Returns absolute path if CWD can be queried, otherwise returns path unchanged. + */ + private resolveExecPathAgainstCwd(pid: number, path: string): string { + const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!getCwd) return path; + const cwdLen = getCwd(pid, this.toKernelPtr(this.scratchOffset), 4096); + if (cwdLen <= 0) return path; + const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); + const cwd = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + cwdLen)); + const joined = cwd.endsWith("/") ? cwd + path : cwd + "/" + path; + // Normalize . and .. components (e.g. /data/spawn/./prog → /data/spawn/prog) + const parts = joined.split("/"); + const normalized: string[] = []; + for (const part of parts) { + if (part === "." || part === "") continue; + if (part === ".." && normalized.length > 0) { normalized.pop(); continue; } + normalized.push(part); + } + return "/" + normalized.join("/"); + } - if (retVal === 0) break; // EOF + /** + * Handle SYS_EXECVEAT: execveat(dirfd, path, argv, envp, flags). + * Used by fexecve which calls execveat(fd, "", argv, envp, AT_EMPTY_PATH). + * Resolves the fd path via kernel_get_fd_path, then delegates to exec flow. + */ + private handleExecveat(channel: ChannelInfo, origArgs: number[]): void { + const AT_EMPTY_PATH = 0x1000; + const dirfd = origArgs[0]; + const flags = origArgs[4]; - // Copy data to process memory - processMem.set( - kernelMem.subarray(kernelBuf, kernelBuf + retVal), - entry.base + entryRead, - ); + const processMem = new Uint8Array(channel.memory.buffer); - entryRead += retVal; - totalRead += retVal; - if (isPreadv) fileOffset += retVal; + // Read path from process memory + const pw = this.getPtrWidth(channel.pid); + const pathResult = this.readExecPathFromProcess(processMem, origArgs[1]); + if ("errno" in pathResult) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, pathResult.errno); + return; + } + const pathStr = pathResult.value; + const argvResult = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + const envResult = this.readStringArrayFromProcess(processMem, origArgs[3], pw); + if ("errno" in argvResult) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, argvResult.errno); + return; + } + if ("errno" in envResult) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, envResult.errno); + return; + } + const argv = argvResult.values; + const envp = envResult.values; - if (retVal < chunkLen) break; // short read - } + let execPath: string; - if (gotEagain || lastErr) break; + if ((flags & AT_EMPTY_PATH) !== 0 && pathStr === "") { + // fexecve path: resolve fd to file path via kernel + const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as + ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!getFdPath) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS + return; } - - if (gotEagain) { - this.handleBlockingRetry(channel, syscallNr, origArgs); + const result = getFdPath(channel.pid, dirfd, this.toKernelPtr(this.scratchOffset), 4096); + if (result <= 0) { + const errno = result < 0 ? (-result) >>> 0 : 2; // ENOENT + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, errno); return; } + const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); + execPath = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + result)); + } else if (pathStr.startsWith("/")) { + execPath = pathStr; + } else { + // Relative path — let kernel resolve against dirfd/CWD. + // For simplicity, resolve against process CWD here. + // The kernel's sys_execveat already resolves this, but since we intercept + // host-side, we need to do it ourselves. + const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + ((pid: number, bufPtr: number, bufLen: number) => number) | undefined; + if (getCwd) { + const cwdLen = getCwd(channel.pid, this.scratchOffset, 4096); + if (cwdLen > 0) { + const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); + const cwd = new TextDecoder().decode( + kernelBuf.slice(this.scratchOffset, this.scratchOffset + cwdLen), + ); + execPath = cwd.endsWith("/") ? cwd + pathStr : cwd + "/" + pathStr; + } else { + execPath = pathStr; + } + } else { + execPath = pathStr; + } + } - const finalRet = totalRead > 0 ? totalRead : (lastErr ? -1 : 0); - const finalErr = totalRead > 0 ? 0 : lastErr; - this.completeChannel(channel, syscallNr, origArgs, undefined, finalRet, finalErr); + if (!this.callbacks.onExec) { + this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS + return; } + + const callerTid = this.channelTids.get(`${channel.pid}:${channel.channelOffset}`) ?? channel.pid; + this.callbacks.onExec(channel.pid, execPath, argv, envp, callerTid).then((result) => { + if (result < 0) { + this.finishFailedExec(channel, SYS_EXECVEAT, origArgs, (-result) >>> 0); + } + }).catch((err) => { + console.error(`[kernel] execveat error for pid ${channel.pid}:`, err); + this.finishFailedExec(channel, SYS_EXECVEAT, origArgs, 5); // EIO + }); } /** - * Handle sendmsg: decompose msghdr from process memory, flatten data + addr - * into kernel scratch, call kernel_sendmsg which dispatches to sendto/send. + * Handle SYS_CLONE: thread creation. Call the onClone callback to spawn + * a thread Worker sharing the parent's Memory. */ - private handleSendmsg(channel: ChannelInfo, origArgs: number[]): void { - const fd = origArgs[0]; - const msgPtr = origArgs[1]; - const flags = origArgs[2]; + private handleClone(channel: ChannelInfo, origArgs: number[]): void { + // Channel args from musl's __clone override which calls kernel_clone directly: + // kernel_clone(fn_ptr, stack_ptr, flags, arg, ptid_ptr, tls_ptr, ctid_ptr) + // The channel syscall path dispatches SYS_CLONE with Linux syscall + // convention: + // a1=flags, a2=stack, a3=ptid, a4=tls, a5=ctid + // The kernel dispatch remaps: kernel_clone(0, a2, a1, 0, a3, a4, a5) + // + // However, programs using the musl overlay's __clone call kernel_clone + // directly as a Wasm import, which means they DON'T go through + // channel_syscall. They use the kernel.kernel_clone import provided + // by buildThreadKernelStubs or the host kernel. So origArgs here + // come from the channel in Linux syscall convention: + // origArgs[0]=flags, [1]=stack, [2]=ptid, [3]=tls, [4]=ctid - const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); + if (!this.callbacks.onClone) { + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 38); + return; + } + + // Route through kernel_handle_channel — the kernel allocates a TID and + // stores ThreadInfo. The dispatch table remaps args correctly. const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const pw = this.getPtrWidth(channel.pid); + kernelView.setUint32(CH_SYSCALL, SYS_CLONE, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(origArgs[i]), true); + } - // Parse msghdr from process memory (ptrWidth-aware). - // wasm32 layout (28B): name(4), namelen(4), iov(4), iovlen(4), control(4), controllen(4), flags(4) - // wasm64 layout (48B): name(8), namelen(4), pad(4), iov(8), iovlen(4), pad(4), control(8), controllen(4), flags(4) - let namePtr: number, nameLen: number, iovPtr: number, iovCnt: number; - let controlPtr: number, controlLen: number; - if (pw === 8) { - namePtr = Number(processView.getBigUint64(msgPtr, true)); - nameLen = processView.getUint32(msgPtr + 8, true); - iovPtr = Number(processView.getBigUint64(msgPtr + 16, true)); - iovCnt = processView.getUint32(msgPtr + 24, true); - controlPtr = Number(processView.getBigUint64(msgPtr + 32, true)); - controlLen = processView.getUint32(msgPtr + 40, true); - } else { - namePtr = processView.getUint32(msgPtr, true); - nameLen = processView.getUint32(msgPtr + 4, true); - iovPtr = processView.getUint32(msgPtr + 8, true); - iovCnt = processView.getUint32(msgPtr + 12, true); - controlPtr = processView.getUint32(msgPtr + 16, true); - controlLen = processView.getUint32(msgPtr + 20, true); + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } finally { + this.currentHandlePid = 0; } - // Build kernel-side msghdr in wasm32 (28B) format (kernel uses explicit u32 parsing) - const kMsgPtr = dataStart; - const kv = new DataView(kernelMem.buffer); - kv.setUint32(kMsgPtr, namePtr, true); - kv.setUint32(kMsgPtr + 4, nameLen, true); - kv.setUint32(kMsgPtr + 8, iovPtr, true); // will be updated below - kv.setUint32(kMsgPtr + 12, iovCnt, true); - kv.setUint32(kMsgPtr + 16, controlPtr, true); // will be updated below - kv.setUint32(kMsgPtr + 20, controlLen, true); - kv.setUint32(kMsgPtr + 24, 0, true); // msg_flags + const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); + const errVal = kernelView.getUint32(CH_ERRNO, true); - let dataOff = 28; // after kernel-format msghdr + if (retVal < 0) { + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, retVal, errVal); + return; + } - // Copy msg_name to kernel scratch - if (namePtr !== 0 && nameLen > 0 && dataOff + nameLen <= CH_DATA_SIZE) { - const kNamePtr = dataStart + dataOff; - kernelMem.set(processMem.subarray(namePtr, namePtr + nameLen), kNamePtr); - kv.setUint32(kMsgPtr, kNamePtr, true); // update msg_name ptr - dataOff += nameLen; - dataOff = (dataOff + 3) & ~3; + const tid = retVal; + + // CLONE_PARENT_SETTID: write TID to ptid_ptr in process memory. + // The host writes this because ptid_ptr is in process memory, not kernel + // memory. + const CLONE_PARENT_SETTID = 0x00100000; + const flags = origArgs[0]; + const ptidPtr = origArgs[2]; + if (flags & CLONE_PARENT_SETTID && ptidPtr !== 0) { + const procView = new DataView(channel.memory.buffer); + procView.setInt32(ptidPtr, tid, true); } - // Copy msg_control (ancillary data, e.g. SCM_RIGHTS) to kernel scratch - if (controlPtr !== 0 && controlLen > 0 && dataOff + controlLen <= CH_DATA_SIZE) { - const kCtrlPtr = dataStart + dataOff; - kernelMem.set(processMem.subarray(controlPtr, controlPtr + controlLen), kCtrlPtr); - kv.setUint32(kMsgPtr + 16, kCtrlPtr, true); // update msg_control ptr - dataOff += controlLen; - dataOff = (dataOff + 3) & ~3; + // Read fnPtr and argPtr from the channel's CH_DATA area (written by kernel_clone stub) + // These are always written as u32 by the glue (even on wasm64, table indices are i32) + const processView = new DataView(channel.memory.buffer, channel.channelOffset); + const fnPtr = processView.getUint32(CH_DATA, true); + const argPtr = processView.getUint32(CH_DATA + 4, true); + const stackPtr = origArgs[1]; + const tlsPtr = origArgs[3]; + const ctidPtr = origArgs[4]; + + // Register the clear-TID pointer before starting the host Worker. A very + // short-lived pthread can reach SYS_EXIT before onClone resolves. + if (ctidPtr !== 0) { + this.threadCtidPtrs.set(`${channel.pid}:${tid}`, ctidPtr); } - // Copy iov array and iov data to kernel scratch - const iovEntrySize = pw === 8 ? 16 : 8; - if (iovCnt > 0 && iovPtr !== 0) { - const kIovSize = iovCnt * 8; // kernel-side iov is always 8 bytes per entry (u32 base + u32 len) - const kIovPtr = dataStart + dataOff; - dataOff += kIovSize; - dataOff = (dataOff + 3) & ~3; + this.callbacks.onClone( + channel.pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, channel.memory, + ).then((assignedTid) => { + // prepareProcessForExec already removed the old generation's metadata. + // A stale continuation must not delete a same pid/tid key now owned by + // the replacement image. + if (!this.isAsyncChannelProcessActive(channel)) return; + if (assignedTid !== tid && ctidPtr !== 0) { + this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); + this.threadCtidPtrs.set(`${channel.pid}:${assignedTid}`, ctidPtr); + } + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, assignedTid, 0); + }).catch((err) => { + if (!this.isAsyncChannelProcessActive(channel)) return; + if (ctidPtr !== 0) { + this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); + } + console.error(`[kernel-worker] onClone failed: ${err}`); + this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); // ENOMEM + }); + } - kv.setUint32(kMsgPtr + 8, kIovPtr, true); // update msg_iov ptr + /** + * Handle SYS_EXIT/SYS_EXIT_GROUP: notify the kernel and clean up. + * + * For SYS_EXIT from a non-main channel (thread exit): notify kernel, + * remove channel, and let the host terminate the backing Worker. If an + * older host entry has no thread-exit callback, fall back to completing the + * channel for compatibility. + * For SYS_EXIT from main channel or SYS_EXIT_GROUP: current behavior. + */ + private handleExit(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { + const exitStatus = origArgs[0]; + const registration = this.processes.get(channel.pid); - // Copy each iov buffer data - for (let i = 0; i < iovCnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); - } else { - base = processView.getUint32(iovPtr + i * 8, true); - len = processView.getUint32(iovPtr + i * 8 + 4, true); - } - // Write kernel-format iov entry (always u32 base + u32 len) - kv.setUint32(kIovPtr + i * 8, 0, true); // will be updated if data copied - kv.setUint32(kIovPtr + i * 8 + 4, len, true); + // Check if this is a thread exit (non-main channel + SYS_EXIT) + const isMainChannel = registration && registration.channels.length > 0 && + registration.channels[0].channelOffset === channel.channelOffset; - if (len > 0 && dataOff + len <= CH_DATA_SIZE) { - const kBufPtr = dataStart + dataOff; - kernelMem.set(processMem.subarray(base, base + len), kBufPtr); - kv.setUint32(kIovPtr + i * 8, kBufPtr, true); - dataOff += len; - dataOff = (dataOff + 3) & ~3; - } + if (syscallNr === SYS_EXIT && !isMainChannel) { + // Thread exit: finalize kernel-side thread state, complete the channel, + // then ask the host to tear down the backing Worker (browser + Node both + // wire onThreadExit). + const tidKey = `${channel.pid}:${channel.channelOffset}`; + const tid = this.channelTids.get(tidKey) ?? 0; + if (tid > 0) + this.finalizeThreadExit(channel.pid, tid, channel.channelOffset); + // Complete — never merely abandon — the channel on thread exit. This + // flips the status word off CH_PENDING so the exiting guest's in-wasm + // memory.atomic.wait32() returns and its waiter is removed while the + // Worker is still alive. Only then may the browser host hard-terminate + // it. Completing after terminate is not sufficient: browser engines may + // leave the dead agent's waiter queued long enough to outlive the freed + // slot. The guest wake protocol notifies exactly one waiter + // (memory.atomic.notify(status, 1)); a stale parked waiter from the prior + // thread then steals the reused thread's first-syscall notify, the + // kernel's Atomics.waitAsync never fires, and the new thread wedges + // forever. Observed as: MariaDB's connection-handler thread (cloned when + // it accepts php-fpm's DB connection) never runs its first syscall, so + // the WordPress-over-MariaDB demo never gets a MySQL greeting and hangs. + this.completeChannelRaw(channel, 0, 0); + if (tid > 0) { + this.callbacks.onThreadExit?.(channel.pid, tid, channel.channelOffset); + } + return; + } + + // Publish and detach while the process still owns its descriptors and + // before waking a parent waiter. Duplicate exit syscalls are harmless. + this.releaseAllSharedMemoryForProcess(channel.pid); + if (this.getProcessExitSignal(channel.pid) > 0) { + if (!this.hostReaped.has(channel.pid)) this.handleProcessTerminated(channel); + return; + } + + // Run the kernel's exit path so it closes all FDs (including pipe + // write ends). kernel_exit calls sys_exit then traps — catch the trap. + { + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + kernelView.setBigInt64(CH_ARGS, BigInt(exitStatus), true); + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch { + // Expected: kernel_exit traps with unreachable after closing FDs + } finally { + this.currentHandlePid = 0; } } - // Call kernel - kernelView.setUint32(CH_SYSCALL, SYS_SENDMSG, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(kMsgPtr), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flags), true); - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + // Main thread exit or exit_group: record exit status for waitpid, + // queue SIGCHLD to parent, then notify the host callback. + const exitingPid = channel.pid; + this.discardStoppedChannelStateForProcess(exitingPid); + // Idempotency: this guard is shared with handleProcessTerminated so a + // SYS_KILL that races a clean SYS_EXIT from the same process doesn't + // produce two SIGCHLDs / two parent wake-ups. Cleared by + // deactivateProcess and registerProcess. + if (this.hostReaped.has(exitingPid)) { + // Already reaped via the kill path — still complete the channel so + // the worker can finish tearing down, but skip the parent-wakeup work. + this.completeChannelRaw(channel, 0, 0); + this.scheduleWakeBlockedRetries(); + if (this.callbacks.onExit) this.callbacks.onExit(exitingPid, exitStatus); + return; } + this.hostReaped.add(exitingPid); + this.notifyParentOfExitedProcess(exitingPid); - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + // Complete the channel so the worker unblocks from Atomics.wait(). + // Without this, the worker stays blocked and Node.js aborts when + // trying to terminate worker threads during process.exit(). + this.completeChannelRaw(channel, 0, 0); - if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, SYS_SENDMSG, origArgs); - return; + // Wake any processes blocked on pipe reads/polls — the exiting process's + // FDs were closed by the kernel (sys_exit), so pipes with no remaining + // writers should now return EOF to readers. + this.scheduleWakeBlockedRetries(); + + if (this.callbacks.onExit) { + this.callbacks.onExit(exitingPid, exitStatus); } + } - this.completeChannel(channel, SYS_SENDMSG, origArgs, undefined, retVal, errVal); + /** + * Handle a process that was terminated by a signal while blocking on a + * syscall retry. Rust already owns the Exited state and wait status; the + * host only wakes the parent waiter and terminates the Worker. + */ + private handleProcessTerminated(channel: ChannelInfo): void { + const exitingPid = channel.pid; + this.discardStoppedChannelStateForProcess(exitingPid); + // Idempotency guard — both handleExit and reapKilledProcessesAfterSyscall + // can route here for the same pid; do the parent-wakeup work exactly + // once per generation. Cleared by deactivateProcess + registerProcess + // so a recycled pid (currently impossible with monotonic nextChildPid, + // but defensive) starts fresh. + if (this.hostReaped.has(exitingPid)) return; + // Mark the transition before publishing shared mappings. A final writeback + // can itself cross the kernel and rediscover the same Exited process; the + // early guard prevents recursive termination cleanup. + // Capture the signal before notifying the parent: a synchronous wait can + // consume and reap the zombie, after which the kernel query returns ESRCH. + const signal = this.getProcessExitSignal(exitingPid); + this.hostReaped.add(exitingPid); + this.releaseAllSharedMemoryForProcess(exitingPid); + this.notifyParentOfExitedProcess(exitingPid); + + // Do NOT complete the channel — the worker is blocked on Atomics.wait + // and waking it would cause the C code to continue executing. + // onExit will terminate the worker. + if (this.callbacks.onExit) { + this.callbacks.onExit(exitingPid, signal > 0 ? 128 + signal : -1); + } } /** - * Handle recvmsg: decompose msghdr from process memory, set up buffers in - * kernel scratch, call kernel_recvmsg, copy results back. + * A stop/continue wake can be superseded by signal death in the same kernel + * dispatch (for example retained SIGTERM delivered immediately by SIGCONT). + * Finalize host teardown before any parent waiter can consume/reap that exit, + * and suppress the stale lifecycle notification. */ - private handleRecvmsg(channel: ChannelInfo, origArgs: number[]): void { - const fd = origArgs[0]; - const msgPtr = origArgs[1]; - const flags = origArgs[2]; + private finalizeExitedProcessBeforeLifecycleNotification( + pid: number, + ): boolean { + const getState = this.kernelInstance!.exports.kernel_get_process_state as + ((pid: number) => number) | undefined; + if (!getState || getState(pid) !== PROCESS_STATE_EXITED) return false; - const processMem = new Uint8Array(channel.memory.buffer); - const processView = new DataView(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const dataStart = this.scratchOffset + CH_DATA; - const pw = this.getPtrWidth(channel.pid); + this.discardStoppedChannelStateForProcess(pid); + if (this.hostReaped.has(pid)) return true; + this.cancelPendingSleepsForProcess(pid); - // Parse msghdr from process memory (ptrWidth-aware) - let namePtr: number, nameLen: number, iovPtr: number, iovCnt: number; - let controlPtr: number, controlLen: number; - if (pw === 8) { - namePtr = Number(processView.getBigUint64(msgPtr, true)); - nameLen = processView.getUint32(msgPtr + 8, true); - iovPtr = Number(processView.getBigUint64(msgPtr + 16, true)); - iovCnt = processView.getUint32(msgPtr + 24, true); - controlPtr = Number(processView.getBigUint64(msgPtr + 32, true)); - controlLen = processView.getUint32(msgPtr + 40, true); + const channel = this.processes.get(pid)?.channels[0]; + if (channel) { + this.handleProcessTerminated(channel); } else { - namePtr = processView.getUint32(msgPtr, true); - nameLen = processView.getUint32(msgPtr + 4, true); - iovPtr = processView.getUint32(msgPtr + 8, true); - iovCnt = processView.getUint32(msgPtr + 12, true); - controlPtr = processView.getUint32(msgPtr + 16, true); - controlLen = processView.getUint32(msgPtr + 20, true); + this.finalizeExecHandoffTermination(pid); } + return true; + } - // Build kernel-side msghdr in wasm32 (28B) format - const kMsgPtr = dataStart; - const kv = new DataView(kernelMem.buffer); - kv.setUint32(kMsgPtr, namePtr, true); - kv.setUint32(kMsgPtr + 4, nameLen, true); - kv.setUint32(kMsgPtr + 8, iovPtr, true); - kv.setUint32(kMsgPtr + 12, iovCnt, true); - kv.setUint32(kMsgPtr + 16, controlPtr, true); - kv.setUint32(kMsgPtr + 20, controlLen, true); - kv.setUint32(kMsgPtr + 24, 0, true); + /** + * Notify the kernel that a host worker for `pid` died asynchronously + * (uncaught wasm trap, instantiation failure, externally terminated + * Worker) WITHOUT going through the normal SYS_EXIT_GROUP path. + * + * Without this, an OOB/instantiation crash leaves the kernel + * believing the process is still alive: any concurrent waitpid in + * the parent then blocks until host destroy. P-06 / K-03 exposed + * this — the child's wasm trapped during _start, the worker + * reported it via `{type:"error"}`, the host posted `stderr` + + * deactivated the process locally, but the kernel never marked the + * pid as a zombie or woke the parent. + * + * Marks the process as signal-terminated in Rust using `signum` (default + * `SIGSEGV` = 11), queues `SIGCHLD` on the parent, and wakes any parked + * `waitpid` / `waitid`. + * + * Idempotent via `hostReaped`: if the kernel already saw a clean + * SYS_EXIT for this pid, this is a no-op (the kernel's exit + * status wins). Host-side cleanup (channel removal, timer + * cancellation) is still the caller's responsibility — call + * `deactivateProcess` after this if the pid is going away. + */ + notifyHostProcessCrashed( + pid: number, + signum: number = 11 /* SIGSEGV */, + ): void { + this.discardStoppedChannelStateForProcess(pid); + if (this.hostReaped.has(pid)) return; + const markSignaled = this.kernelInstance!.exports + .kernel_mark_process_signaled as + ((pid: number, signum: number) => number) | undefined; + if (markSignaled && markSignaled(pid, signum) < 0) return; + this.hostReaped.add(pid); + this.releaseAllSharedMemoryForProcess(pid); + this.notifyParentOfExitedProcess(pid); + } - let dataOff = 28; + /** + * After SYS_KILL completes, scan for processes the kernel just marked + * Exited that the host hasn't reaped. Without this, a `kill` of a + * sleeping child (or any process not blocked in poll/select/pipe — those + * are handled by scheduleWakeBlockedRetries) silently reaps the process + * at the kernel level but can leave the host-side blocked wait queue + * asleep — wait4(-1) then blocks forever. + * + * The kernel exposes the termination signal separately from the normal exit + * status, so exit codes 128..255 cannot be mistaken for signal death. + */ + private reapKilledProcessesAfterSyscall(): void { + // Snapshot the registered pids so we can mutate this.processes safely + // inside the loop (handleProcessTerminated calls onExit which can + // remove entries). + const pids = Array.from(this.processes.keys()); + for (const pid of pids) { + if (this.getProcessExitSignal(pid) <= 0) continue; + if (this.hostReaped.has(pid)) continue; // already reaped this generation - // Set up msg_name output buffer - let kNamePtr = 0; - if (namePtr !== 0 && nameLen > 0 && dataOff + nameLen <= CH_DATA_SIZE) { - kNamePtr = dataStart + dataOff; - kernelMem.fill(0, kNamePtr, kNamePtr + nameLen); - kv.setUint32(kMsgPtr, kNamePtr, true); - dataOff += nameLen; - dataOff = (dataOff + 3) & ~3; + // Cancel any pending blocking-syscall timers — the process is gone. + this.cancelPendingSleepsForProcess(pid); + + const proc = this.processes.get(pid); + const ch = proc?.channels[0]; + // handleProcessTerminated re-checks hostReaped and adds the pid + // itself, so passing through here is idempotent if two reap + // events fire close together. + if (ch) this.handleProcessTerminated(ch); } + } - // Set up msg_control output buffer for ancillary data (SCM_RIGHTS) - let kCtrlPtr = 0; - if (controlPtr !== 0 && controlLen > 0 && dataOff + controlLen <= CH_DATA_SIZE) { - kCtrlPtr = dataStart + dataOff; - kernelMem.fill(0, kCtrlPtr, kCtrlPtr + controlLen); - kv.setUint32(kMsgPtr + 16, kCtrlPtr, true); - dataOff += controlLen; - dataOff = (dataOff + 3) & ~3; + private getProcessExitSignal(pid: number): number { + const getExitSignal = this.kernelInstance!.exports + .kernel_get_process_exit_signal as ((pid: number) => number) | undefined; + if (!getExitSignal) { + throw new Error("Kernel missing required kernel_get_process_exit_signal export"); } + return getExitSignal(pid); + } - // Set up iov array and output buffers - interface IovEntry { base: number; len: number; kernelBase: number } - const entries: IovEntry[] = []; - const iovEntrySize = pw === 8 ? 16 : 8; + /** Stop a channel boundary when signal delivery transitioned its process to Exited. */ + private finishSignalTermination(channel: ChannelInfo): boolean { + if (this.getProcessExitSignal(channel.pid) <= 0) return false; + this.cancelPendingSleepsForProcess(channel.pid); + this.handleProcessTerminated(channel); + return true; + } - if (iovCnt > 0 && iovPtr !== 0) { - const kIovSize = iovCnt * 8; // kernel-side iov always 8B per entry - const kIovPtr = dataStart + dataOff; - dataOff += kIovSize; - dataOff = (dataOff + 3) & ~3; + /** + * Finalize a signal death that occurred while exec had no registered host + * channel. The kernel Process is already an Exited zombie; this performs the + * parent notification and host exit callback exactly once. The caller must + * not install a replacement worker when the returned signal is positive. + */ + finalizeExecHandoffTermination(pid: number): number { + const signal = this.getProcessExitSignal(pid); + if (signal <= 0) return signal; + this.discardStoppedChannelStateForProcess(pid); + if (this.hostReaped.has(pid)) return signal; - kv.setUint32(kMsgPtr + 8, kIovPtr, true); + this.hostReaped.add(pid); + this.releaseAllSharedMemoryForProcess(pid); + this.notifyParentOfExitedProcess(pid); + if (this.callbacks.onExit) { + this.callbacks.onExit(pid, 128 + signal); + } + return signal; + } - for (let i = 0; i < iovCnt; i++) { - let base: number, len: number; - if (pw === 8) { - base = Number(processView.getBigUint64(iovPtr + i * iovEntrySize, true)); - len = Number(processView.getBigUint64(iovPtr + i * iovEntrySize + 8, true)); - } else { - base = processView.getUint32(iovPtr + i * 8, true); - len = processView.getUint32(iovPtr + i * 8 + 4, true); - } - if (len > 0 && dataOff + len <= CH_DATA_SIZE) { - const kBufPtr = dataStart + dataOff; - kernelMem.fill(0, kBufPtr, kBufPtr + len); - kv.setUint32(kIovPtr + i * 8, kBufPtr, true); - kv.setUint32(kIovPtr + i * 8 + 4, len, true); - entries.push({ base, len, kernelBase: kBufPtr }); - dataOff += len; - dataOff = (dataOff + 3) & ~3; - } else { - kv.setUint32(kIovPtr + i * 8, 0, true); - kv.setUint32(kIovPtr + i * 8 + 4, len, true); - } + /** + * Finalize a signal that reached a fork/spawn child while its async Worker + * launch had no dispatchable channel. The child remains a real zombie for + * parent wait semantics, but eager host fd mirrors must be retired. + */ + finalizePendingChildTermination(pid: number): number { + const exitSignal = this.finalizeExecHandoffTermination(pid); + if (exitSignal !== -1) { + this.cleanupTcpListeners(pid); + for (const key of Array.from(this.epollInterests.keys())) { + if (key.startsWith(`${pid}:`)) this.epollInterests.delete(key); } } + return exitSignal; + } - // Call kernel - kernelView.setUint32(CH_SYSCALL, SYS_RECVMSG, true); - kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(kMsgPtr), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(flags), true); + /** Track pids the host has already reaped (prevents double-reaping + * when reapKilledProcessesAfterSyscall is called multiple times for + * the same already-Exited process). Cleared when the pid is + * re-allocated by a fresh fork+register. */ + private hostReaped = new Set(); + + /** + * Handle SYS_WAIT4: wait for a child process to exit. + * Args: [pid, wstatus_ptr, options, rusage_ptr] + */ + private handleWaitpid(channel: ChannelInfo, origArgs: number[]): void { + const targetPid = origArgs[0]; // pid argument + const wstatusPtr = origArgs[1]; + const options = origArgs[2] >>> 0; + const rusagePtr = origArgs[3]; + const parentPid = channel.pid; - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + if (this.pendingCancels.delete(channel)) { + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(channel); + return; } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + const allowedOptions = + WAIT_WNOHANG | WAIT_WUNTRACED | WAIT_WSTOPPED | WAIT_WCONTINUED; + if ((options & ~allowedOptions) !== 0) { + this.completeWaitpid(channel, origArgs, -1, EINVAL); + return; + } + if ( + !this.isOptionalGuestOutputRangeValid(channel, wstatusPtr, 4) || + !this.isOptionalGuestOutputRangeValid( + channel, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ) + ) { + this.completeWaitpid(channel, origArgs, -1, EFAULT); + return; + } - if (retVal === -1 && errVal === EAGAIN) { - this.handleBlockingRetry(channel, SYS_RECVMSG, origArgs); + const eventMask = this.wait4EventMask(options); + const poll = this.pollWaitableChild(parentPid, targetPid, eventMask, 0); + if (poll.kind === "error") { + this.completeWaitpid(channel, origArgs, -1, poll.errno); + return; + } + if (poll.kind === "event") { + this.writeWait4Result(channel, wstatusPtr, rusagePtr, poll); + this.completeWaitpid(channel, origArgs, poll.childPid, 0); return; } - // Copy received data back to process memory - if (retVal > 0) { - let remaining = retVal; - for (const entry of entries) { - if (remaining <= 0) break; - const copyLen = Math.min(entry.len, remaining); - processMem.set( - kernelMem.subarray(entry.kernelBase, entry.kernelBase + copyLen), - entry.base, - ); - remaining -= copyLen; - } + if (options & WAIT_WNOHANG) { + this.completeWaitpid(channel, origArgs, 0, 0); + return; } - // Copy msg_name (source address) back to process memory - if (kNamePtr !== 0 && namePtr !== 0 && nameLen > 0) { - processMem.set(kernelMem.subarray(kNamePtr, kNamePtr + nameLen), namePtr); + const pendingWaiter: WaitingForChild = { + parentPid, + channel, + origArgs, + pid: targetPid, + options, + syscallNr: SYS_WAIT4, + }; + if (this.interruptWaiterWithPendingSignal(pendingWaiter)) return; + + // Blocking wait: defer completion until a child exits + this.waitingForChild.push(pendingWaiter); + } + + private wait4EventMask(options: number): number { + let eventMask = WAIT_EVENT_EXITED; + if ((options & (WAIT_WUNTRACED | WAIT_WSTOPPED)) !== 0) { + eventMask |= WAIT_EVENT_STOPPED; } + if ((options & WAIT_WCONTINUED) !== 0) { + eventMask |= WAIT_EVENT_CONTINUED; + } + return eventMask; + } - // Copy msg_control (ancillary data) back to process memory - if (kCtrlPtr !== 0 && controlPtr !== 0) { - const actualControlLen = kv.getUint32(kMsgPtr + 20, true); - if (actualControlLen > 0 && actualControlLen <= controlLen) { - processMem.set( - kernelMem.subarray(kCtrlPtr, kCtrlPtr + actualControlLen), - controlPtr, - ); - } + private waitidEventMask(options: number): number { + let eventMask = 0; + if ((options & WAIT_WEXITED) !== 0) eventMask |= WAIT_EVENT_EXITED; + if ((options & (WAIT_WSTOPPED | WAIT_WUNTRACED)) !== 0) { + eventMask |= WAIT_EVENT_STOPPED; } + if ((options & WAIT_WCONTINUED) !== 0) eventMask |= WAIT_EVENT_CONTINUED; + return eventMask; + } - // Copy updated msghdr fields back to process memory (ptrWidth-aware) - const kNamelenVal = kv.getUint32(kMsgPtr + 4, true); - const kControllenVal = kv.getUint32(kMsgPtr + 20, true); - const kMsgflags = kv.getUint32(kMsgPtr + 24, true); - if (pw === 8) { - processView.setUint32(msgPtr + 8, kNamelenVal, true); // msg_namelen - processView.setUint32(msgPtr + 40, kControllenVal, true); // msg_controllen - processView.setUint32(msgPtr + 44, kMsgflags, true); // msg_flags - } else { - processView.setUint32(msgPtr + 4, kNamelenVal, true); // msg_namelen - processView.setUint32(msgPtr + 20, kControllenVal, true); // msg_controllen - processView.setUint32(msgPtr + 24, kMsgflags, true); // msg_flags + private pollWaitableChild( + parentPid: number, + targetPid: number, + eventMask: number, + flags: number, + ): WaitPollResult { + const waitPoll = this.kernelInstance!.exports.kernel_wait_child_poll as ( + parentPid: number, + targetPid: number, + eventMask: number, + flags: number, + resultPtr: KernelPointer, + ) => number; + const result = waitPoll( + parentPid, + targetPid, + eventMask, + flags, + this.toKernelPtr(this.scratchOffset), + ); + if (result > 0) { + const source = new Uint8Array( + this.kernelMemory!.buffer, + this.scratchOffset, + STRUCT_SIZE_KERNEL_WAIT_RESULT, + ); + const owned = new Uint8Array(STRUCT_SIZE_KERNEL_WAIT_RESULT); + owned.set(source); + const view = new DataView(owned.buffer); + const rusage = owned.subarray( + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, + KERNEL_WAIT_RESULT_RUSAGE_OFFSET + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ); + return { + kind: "event", + childPid: result, + waitStatus: view.getInt32(KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, true), + siCode: view.getInt32(KERNEL_WAIT_RESULT_SI_CODE_OFFSET, true), + siStatus: view.getInt32(KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, true), + childUid: view.getUint32(KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, true), + rusage, + }; } + if (result === 0) return { kind: "running" }; + return { kind: "error", errno: -result >>> 0 }; + } - this.completeChannel(channel, SYS_RECVMSG, origArgs, undefined, retVal, errVal); + private isOptionalGuestOutputRangeValid( + channel: ChannelInfo, + ptr: number, + size: number, + ): boolean { + return ( + ptr === 0 || + isValidMemoryRange(new Uint8Array(channel.memory.buffer), ptr, size) + ); } - // ----------------------------------------------------------------------- - // Fork/exec/clone/exit handling - // ----------------------------------------------------------------------- + private isRequiredGuestOutputRangeValid( + channel: ChannelInfo, + ptr: number, + size: number, + ): boolean { + return isValidMemoryRange(new Uint8Array(channel.memory.buffer), ptr, size); + } - /** - * Handle SYS_FORK/SYS_VFORK: clone the Process in the kernel's ProcessTable, - * then call the onFork callback to spawn the child Worker. - */ - private handleFork(channel: ChannelInfo, _origArgs: number[]): void { - if (!this.callbacks.onFork) { - // No fork handler — return -ENOSYS - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 38); + private getParentPid(pid: number): number | undefined { + const getParentPid = this.kernelInstance!.exports.kernel_get_parent_pid as ( + pid: number, + ) => number; + const result = getParentPid(pid); + return result > 0 ? result : undefined; + } + + private consumeExitedChild(parentPid: number, childPid: number): void { + const reapChild = this.kernelInstance!.exports.kernel_reap_exited_child as ( + parentPid: number, + childPid: number, + ) => number; + reapChild(parentPid, childPid); + } + + private notifyParentOfExitedProcess(pid: number): void { + const parentPid = this.getParentPid(pid); + if (parentPid === undefined) return; + + const hasNoCldWait = this.kernelInstance!.exports + .kernel_has_sa_nocldwait as ((pid: number) => number) | undefined; + const autoReap = hasNoCldWait ? hasNoCldWait(parentPid) === 1 : false; + if (autoReap) { + this.consumeExitedChild(parentPid, pid); + // A parent may already be blocked in a wait for this child. Re-poll it + // after auto-reap so it observes ECHILD instead of sleeping forever. + this.wakeWaitingParent(parentPid); return; } - const parentPid = channel.pid; - // Skip pids that are already registered (e.g., pid 3 is nginx master) - while (this.processes.has(this.nextChildPid)) { - this.nextChildPid++; - } - const childPid = this.nextChildPid++; + this.sendSignalToProcess(parentPid, SIGCHLD); + } - // Clone the Process in the kernel's ProcessTable - const kernelForkProcess = this.kernelInstance!.exports.kernel_fork_process as - (parentPid: number, childPid: number) => number; - const forkResult = kernelForkProcess(parentPid, childPid); - if (forkResult < 0) { - // Fork failed in kernel (e.g., ESRCH, ENOMEM) - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, (-forkResult) >>> 0); - return; + private writeWait4Result( + channel: ChannelInfo, + wstatusPtr: number, + rusagePtr: number, + result: Extract, + ): void { + const processMem = new Uint8Array(channel.memory.buffer); + if (wstatusPtr !== 0) { + new DataView(channel.memory.buffer).setInt32( + wstatusPtr, + result.waitStatus, + true, + ); } + if (rusagePtr !== 0) processMem.set(result.rusage, rusagePtr); + } - // Clear fork_child flag immediately. With wpk_fork instrumentation, the - // child resumes from the fork point and never checks this flag. Without - // clearing it, a nested fork() from the child would hit the isForkChild - // check above and return 0 instead of creating a grandchild. - const clearForkChild = this.kernelInstance!.exports.kernel_clear_fork_child as - ((pid: number) => number) | undefined; - if (clearForkChild) clearForkChild(childPid); + /** Complete a waitpid syscall. */ + private completeWaitpid( + channel: ChannelInfo, + origArgs: number[], + retVal: number, + errVal: number, + ): void { + // Waitpid is handled host-side (never goes through kernel_handle_channel), + // so we must check for pending signals here. Without this, cross-process + // signals (e.g., kill from child to parent) are lost — the signal is queued + // in the kernel but never dequeued for the blocked parent. + this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return; + this.completeChannel( + channel, + SYS_WAIT4, + origArgs, + undefined, + retVal, + errVal, + ); + } - // Clear the child's blocked signal mask. With wpk_fork instrumentation, - // musl's __restore_sigs after fork() runs in the child, but we clear it - // here too for safety. Without fork instrumentation, the child re-executes - // _start and never gets __restore_sigs. - const resetSignalMask = this.kernelInstance!.exports.kernel_reset_signal_mask as - ((pid: number) => number) | undefined; - if (resetSignalMask) resetSignalMask(childPid); + private completeWaitid( + channel: ChannelInfo, + origArgs: number[], + retVal: number, + errVal: number, + ): void { + this.dequeueSignalForDelivery(channel, true); + if (this.finishSignalTermination(channel)) return; + this.completeChannel( + channel, + SYS_WAITID, + origArgs, + undefined, + retVal, + errVal, + ); + } - // If the syscall arrived on a thread channel (registered via clone() - // with tid > 0), the wpk_fork save buffer is at THIS channel's offset - // and the unwind frames are rooted in the pthread entry function, not - // _start. Pass that context to onFork so the child Worker can rewind - // correctly. - const threadKey = `${parentPid}:${channel.channelOffset}`; - const threadCtx = this.threadForkContexts.get(threadKey); - const callerSlotStart = - channel.channelOffset - PROCESS_MEMORY_THREAD_SLOT_CHANNEL_PRIMARY_PAGE * WASM_PAGE_SIZE; - const callerSlotLen = PROCESS_MEMORY_PAGES_PER_THREAD_SLOT * WASM_PAGE_SIZE; - const threadFork: ForkFromThreadContext | undefined = threadCtx - ? { - fnPtr: threadCtx.fnPtr, - argPtr: threadCtx.argPtr, - forkBufAddr: channel.channelOffset - FORK_BUF_SIZE, - slotStart: callerSlotStart, - slotLen: callerSlotLen, + /** + * Interrupt one exact host-deferred wait after copying its caught signal to + * that thread's channel. The libc glue runs the handler and transparently + * reissues wait4/waitid when the delivered action has SA_RESTART. + */ + private interruptWaiterWithPendingSignal(waiter: WaitingForChild): boolean { + const deliveredSignal = this.dequeueSignalForDelivery(waiter.channel, true); + if (this.finishSignalTermination(waiter.channel)) return true; + if (deliveredSignal <= 0) return false; + + this.completeChannel( + waiter.channel, + waiter.syscallNr, + waiter.origArgs, + undefined, + -1, + EINTR_ERRNO, + ); + return true; + } + + /** + * Give already-available child status priority, then interrupt the one wait + * thread selected by the kernel for `signum`. This mirrors the exact-thread + * selection used by the other host-owned blocking operations. + */ + private interruptWaitingChildForSignal( + targetPid: number, + signum: number, + ): boolean { + this.wakeWaitingParent(targetPid); + + const pickSignalTarget = this.kernelInstance!.exports + .kernel_pick_signal_target_tid as (pid: number, signum: number) => number; + const targetTid = pickSignalTarget(targetPid, signum); + if (targetTid <= 0) return false; + + const waiterIndex = this.waitingForChild.findIndex( + (waiter) => + waiter.parentPid === targetPid && + this.isRegisteredChannel(waiter.channel) && + this.guestTidForChannel(waiter.channel) === targetTid, + ); + if (waiterIndex < 0) return false; + + const [waiter] = this.waitingForChild.splice(waiterIndex, 1); + if (this.interruptWaiterWithPendingSignal(waiter)) return true; + + // The signal may have been consumed or changed disposition between target + // selection and dequeue. Preserve the original wait in that rare race. + this.waitingForChild.splice(waiterIndex, 0, waiter); + return false; + } + + /** Interrupt a wait owned by the exact pthread targeted by tkill(). */ + private interruptWaitingChildForDirectedSignal( + pid: number, + tid: number, + ): boolean { + this.wakeWaitingParent(pid); + const threadHasDeliverable = this.kernelInstance!.exports + .kernel_thread_has_deliverable as (pid: number, tid: number) => number; + if (threadHasDeliverable(pid, tid) <= 0) return false; + + const waiterIndex = this.waitingForChild.findIndex( + (waiter) => + waiter.parentPid === pid && + this.isRegisteredChannel(waiter.channel) && + this.guestTidForChannel(waiter.channel) === tid, + ); + if (waiterIndex < 0) return false; + + const [waiter] = this.waitingForChild.splice(waiterIndex, 1); + if (this.interruptWaiterWithPendingSignal(waiter)) return true; + this.waitingForChild.splice(waiterIndex, 0, waiter); + return false; + } + + /** Service waiters after a guest-originated kill, including pid/group sends. */ + private interruptWaitingChildrenForGeneratedSignal(signum: number): void { + if (signum <= 0) return; + const waitingForChild = this.waitingForChild ?? []; + const parentPids = new Set( + waitingForChild.map((waiter) => waiter.parentPid), + ); + for (const parentPid of parentPids) { + this.interruptWaitingChildForSignal(parentPid, signum); + } + } + + /** Wake a parent blocked in waitpid/waitid when a child exits. */ + private wakeWaitingParent(parentPid: number): void { + this.waitingForChild ??= []; + const resolved: Array<{ + waiter: WaitingForChild; + poll: Exclude; + }> = []; + + // Poll every matching waiter before publishing any completion. Each + // positive result is copied out of kernel scratch by pollWaitableChild, + // so later polls and signal delivery cannot overwrite it. A consuming + // waiter may reap the selected child; a following exact waiter then + // becomes immediately resolvable as ECHILD rather than remaining parked. + for (let i = 0; i < this.waitingForChild.length;) { + const waiter = this.waitingForChild[i]; + if (waiter.parentPid !== parentPid) { + i++; + continue; + } + if (!this.isRegisteredChannel(waiter.channel)) { + this.waitingForChild.splice(i, 1); + continue; + } + const eventMask = + waiter.syscallNr === SYS_WAITID + ? this.waitidEventMask(waiter.options) + : this.wait4EventMask(waiter.options); + const pollFlags = + waiter.syscallNr === SYS_WAITID ? waiter.options & WAIT_WNOWAIT : 0; + const waiterPoll = this.pollWaitableChild( + waiter.parentPid, + waiter.pid, + eventMask, + pollFlags, + ); + if (waiterPoll.kind === "running") { + i++; + continue; + } + this.waitingForChild.splice(i, 1); + resolved.push({ waiter, poll: waiterPoll }); + } + + for (const { waiter, poll } of resolved) { + if (poll.kind === "error") { + if (waiter.syscallNr === SYS_WAITID) { + this.completeWaitid(waiter.channel, waiter.origArgs, -1, poll.errno); + } else { + this.completeWaitpid(waiter.channel, waiter.origArgs, -1, poll.errno); } - : undefined; + continue; + } - if (threadFork) { - try { - this.reserveHostRegionAt(childPid, threadFork.slotStart, threadFork.slotLen); - } catch (err) { - this.removeFromKernelProcessTable(childPid); - const message = err instanceof Error ? err.message : String(err); - console.error(`[kernel-worker] fork child slot reservation failed: ${message}`); - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); - return; + if (waiter.syscallNr === SYS_WAITID) { + this.writeWaitidResult( + waiter.channel, + waiter.origArgs[2], + waiter.origArgs[4], + poll, + ); + this.completeWaitid(waiter.channel, waiter.origArgs, 0, 0); + } else { + this.writeWait4Result( + waiter.channel, + waiter.origArgs[1], + waiter.origArgs[3], + poll, + ); + this.completeWaitpid(waiter.channel, waiter.origArgs, poll.childPid, 0); } } + } - // Call the async fork handler to spawn child Worker - this.callbacks.onFork(parentPid, childPid, channel.memory, threadFork).then((childChannelOffsets) => { - if (!this.processes.has(parentPid)) return; - - // Inherit TCP listener targets: if parent listens on a port, register - // the child as an additional target (fork children share listening sockets) - for (const [port, targets] of this.tcpListenerTargets) { - const parentTarget = targets.find(t => t.pid === parentPid); - if (parentTarget && !targets.some(t => t.pid === childPid)) { - targets.push({ pid: childPid, fd: parentTarget.fd }); - } - } + /** + * Re-check deferred waitpid/waitid calls after a process group change. + * When a child changes its pgid (setpgid/setsid), a parent waiting on + * waitpid(-pgid) may no longer have matching children → return ECHILD. + */ + private recheckDeferredWaitpids(): void { + const parentsWithNewlyMatchingStatus = new Set(); + // Iterate backwards to safely splice while iterating + for (let i = this.waitingForChild.length - 1; i >= 0; i--) { + const waiter = this.waitingForChild[i]; + // Only re-check waiters targeting a specific process group (pid < -1 or pid == 0) + if (waiter.pid > 0 || waiter.pid === -1) continue; - // Inherit epoll interest lists from parent - for (const [key, interests] of this.epollInterests) { - if (key.startsWith(`${parentPid}:`)) { - const epfd = key.slice(key.indexOf(':') + 1); - this.epollInterests.set(`${childPid}:${epfd}`, interests.map(e => ({ ...e }))); + const eventMask = + waiter.syscallNr === SYS_WAITID + ? this.waitidEventMask(waiter.options) + : this.wait4EventMask(waiter.options); + // This is a membership/ECHILD probe, not a wait completion. Never let + // a process-group change silently consume or reap an eligible event. + const pollFlags = WAIT_WNOWAIT; + const poll = this.pollWaitableChild( + waiter.parentPid, + waiter.pid, + eventMask, + pollFlags, + ); + if (poll.kind === "error") { + // No more matching children — wake with ECHILD + this.waitingForChild.splice(i, 1); + if (waiter.syscallNr === SYS_WAITID) { + this.completeWaitid(waiter.channel, waiter.origArgs, -1, poll.errno); + } else { + this.completeWaitpid(waiter.channel, waiter.origArgs, -1, poll.errno); } + } else if (poll.kind === "event") { + // The pgid change can make an already-recorded status newly eligible. + // The WNOWAIT membership probe deliberately preserved it; service the + // parent's waiters afterward with their real consuming/peek flags. + parentsWithNewlyMatchingStatus.add(waiter.parentPid); } + } - // Complete parent's channel with child PID - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, childPid, 0); - }).catch(() => { - // Fork failed — remove child from kernel ProcessTable - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as - (pid: number) => number; - removeProcess(childPid); - - // Return -ENOMEM to parent - this.completeChannel(channel, SYS_FORK, _origArgs, undefined, -1, 12); - }); + for (const parentPid of parentsWithNewlyMatchingStatus) { + this.wakeWaitingParent(parentPid); + } } /** - * Handle SYS_SPAWN: read the blob and `path` from caller memory, copy - * the blob to kernel scratch, ask the kernel to allocate a child pid + - * build the child Process descriptor, then call `onSpawn` to launch a - * fresh worker for that pid. - * - * Channel arg layout (per docs/plans/2026-05-04-non-forking-posix-spawn-design.md): - * arg0 = path_ptr (caller memory; PATH-resolved) - * arg1 = path_len - * arg2 = blob_ptr (caller memory) - * arg3 = blob_len - * arg4 = pid_out_ptr (caller writes child pid here on success) - * arg5 = 0 (reserved) - * - * Returns 0 on success / -errno on failure via the channel; the child - * pid is delivered through `pid_out_ptr` rather than the return value - * so callers can distinguish "kernel error" (negative) from "got a - * child" (zero, then read pid_out). + * Handle SYS_WAITID: wait for a child process state change. + * Args: [idtype, id, siginfo_ptr, options, rusage_ptr] * - * If `onSpawn` returns non-zero or rejects, the kernel-side child - * descriptor is rolled back via `kernel_remove_process` so the spawn - * attempt leaves no trace. + * Supports P_PID, P_ALL, P_PGID id types and WNOWAIT/WNOHANG/WEXITED flags. + * Fills siginfo_t in process memory with si_signo, si_code, si_pid, si_uid, si_status. */ - private handleSpawn(channel: ChannelInfo, origArgs: number[]): void { + private handleWaitid(channel: ChannelInfo, origArgs: number[]): void { + const idtype = origArgs[0]; + const id = origArgs[1]; + const siginfoPtr = origArgs[2]; + const options = origArgs[3] >>> 0; + const rusagePtr = origArgs[4]; const parentPid = channel.pid; - const pathPtr = origArgs[0]; - const pathLen = origArgs[1]; - const blobPtr = origArgs[2]; - const blobLen = origArgs[3]; - const pidOutPtr = origArgs[4]; + const waitPid = this.waitidToWaitPid(idtype, id); - if (!this.callbacks.onSpawn || !this.callbacks.onResolveSpawn) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 38); // ENOSYS + if (this.pendingCancels.delete(channel)) { + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(channel); return; } - // ── Read path + blob from caller memory ── - const processMem = new Uint8Array(channel.memory.buffer); - let path = ""; - if (pathPtr !== 0 && pathLen > 0) { - path = new TextDecoder().decode(processMem.slice(pathPtr, pathPtr + pathLen)); - // Strip trailing NUL if the user copied a C string with the terminator. - if (path.endsWith("\0")) path = path.slice(0, -1); + const allowedOptions = + WAIT_WNOHANG | + WAIT_WNOWAIT | + WAIT_WEXITED | + WAIT_WSTOPPED | + WAIT_WUNTRACED | + WAIT_WCONTINUED; + const eventMask = this.waitidEventMask(options); + if ( + waitPid === undefined || + (options & ~allowedOptions) !== 0 || + eventMask === 0 + ) { + this.completeWaitid(channel, origArgs, -1, EINVAL); + return; } - const rawPath = path; - if (path && !path.startsWith("/")) { - path = this.resolveExecPathAgainstCwd(parentPid, path); + if ( + !this.isRequiredGuestOutputRangeValid(channel, siginfoPtr, 128) || + !this.isOptionalGuestOutputRangeValid( + channel, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ) + ) { + this.completeWaitid(channel, origArgs, -1, EFAULT); + return; } - if (blobLen <= 0 || (blobPtr === 0 && blobLen > 0)) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL + const poll = this.pollWaitableChild( + parentPid, + waitPid, + eventMask, + options & WAIT_WNOWAIT, + ); + if (poll.kind === "error") { + this.completeWaitid(channel, origArgs, -1, poll.errno); return; } - // .slice copies into a regular ArrayBuffer (TextDecoder rejects SAB views). - const blobBytes = processMem.slice(blobPtr, blobPtr + blobLen); - - // ── Decode argv + envp host-side ── - // The kernel parses the blob too, but onSpawn needs string[] for the - // worker launch path. We don't redo action/attr parsing here; the - // kernel is the authoritative parser for that surface. - let argv: string[]; - let envp: string[]; - try { - const decoded = decodeSpawnBlobStrings(blobBytes); - argv = decoded.argv; - envp = decoded.envp; - } catch (_e) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL + if (poll.kind === "event") { + this.writeWaitidResult(channel, siginfoPtr, rusagePtr, poll); + this.completeWaitid(channel, origArgs, 0, 0); return; } - // ── PRE-FLIGHT: resolve program bytes BEFORE calling the kernel ── - // POSIX requires file_actions to run "exactly once." `posix_spawnp`'s - // PATH search emits one `posix_spawn` per candidate; if we let the - // kernel apply file_actions on each iteration, the side effects - // (e.g. `addopen(O_EXCL)`) accumulate and the second iteration sees - // its own state from the first. Resolve bytes via the host's - // side-effect-free preflight first; only call the kernel if the - // program actually exists. - const resolveSpawnProgram = async (): Promise => { - const resolved = await this.callbacks.onResolveSpawn!(path, argv); - if (resolved || rawPath === path || !rawPath || rawPath.startsWith("/")) { - return resolved; - } + if (options & WAIT_WNOHANG) { + new Uint8Array(channel.memory.buffer, siginfoPtr, 128).fill(0); + this.completeWaitid(channel, origArgs, 0, 0); + return; + } - // SYS_SPAWN is also used by posix_spawnp-style PATH probes. Those - // callers may hand us a relative executable name that exists only in - // the host execPrograms map, not in the kernel VFS at CWD/name. - // Keep the CWD-resolved path as the primary POSIX exec target, but - // fall back to the original token for host-side program maps. - return this.callbacks.onResolveSpawn!(rawPath, argv); + const pendingWaiter: WaitingForChild = { + parentPid, + channel, + origArgs, + pid: waitPid, + options, + syscallNr: SYS_WAITID, }; + if (this.interruptWaiterWithPendingSignal(pendingWaiter)) return; - resolveSpawnProgram().then((resolved) => { - if (!resolved) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 2); // ENOENT - return; - } - if (isSpawnResolveError(resolved)) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, resolved.errno >>> 0); - return; - } - const programBytes = resolved instanceof ArrayBuffer ? resolved : resolved.programBytes; - const launchArgv = resolved instanceof ArrayBuffer ? argv : resolved.argv; - this.handleSpawnAfterResolve( - channel, origArgs, parentPid, pidOutPtr, blobBytes, blobLen, launchArgv, envp, programBytes, - ); - }).catch((err) => { - console.error(`[kernel] spawn resolve error for parent ${parentPid}:`, err); - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 5); // EIO - }); + // Blocking wait: defer until a child exits. + this.waitingForChild.push(pendingWaiter); + } + + private waitidToWaitPid(idtype: number, id: number): number | undefined { + if (!Number.isSafeInteger(id)) return undefined; + if (idtype === P_PID) { + return id > 0 && id <= 0x7fffffff ? id : undefined; + } + if (idtype === P_PGID) { + return id >= 0 && id <= 0x7fffffff ? (id === 0 ? 0 : -id) : undefined; + } + if (idtype === P_ALL) return -1; + return undefined; } /** - * Continuation of `handleSpawn` after `onResolveSpawn` has returned - * actual program bytes. Now safe to ask the kernel to build the child - * (which will apply file_actions exactly once). + * Write siginfo_t fields for waitid into process memory. + * Layout (wasm32): si_signo(+0), si_errno(+4), si_code(+8), + * si_pid(+12), si_uid(+16), si_status(+20) */ - private handleSpawnAfterResolve( + private writeWaitidResult( channel: ChannelInfo, - origArgs: number[], - parentPid: number, - pidOutPtr: number, - blobBytes: Uint8Array, - blobLen: number, - argv: string[], - envp: string[], - programBytes: ArrayBuffer, + siginfoPtr: number, + rusagePtr: number, + result: Extract, ): void { - // ── Copy blob to kernel scratch ── - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - if (blobLen > kernelMem.byteLength - this.scratchOffset) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 22); // EINVAL - return; - } - kernelMem.set(blobBytes, this.scratchOffset); + const processMem = new Uint8Array(channel.memory.buffer); + const procView = new DataView(channel.memory.buffer); + processMem.fill(0, siginfoPtr, siginfoPtr + 128); + procView.setInt32(siginfoPtr + 0, SIGCHLD, true); // si_signo + procView.setInt32(siginfoPtr + 8, result.siCode, true); // si_code + // musl aligns siginfo_t's union to `long`: +12 for wasm32, +16 for + // wasm64. The pid/uid pair is followed by the status union member. + const fieldsOffset = this.getPtrWidth(channel.pid) === 8 ? 16 : 12; + procView.setInt32(siginfoPtr + fieldsOffset, result.childPid, true); // si_pid + procView.setUint32(siginfoPtr + fieldsOffset + 4, result.childUid, true); // si_uid + procView.setInt32(siginfoPtr + fieldsOffset + 8, result.siStatus, true); // si_status + if (rusagePtr !== 0) processMem.set(result.rusage, rusagePtr); + } - // ── Ask the kernel to build the child descriptor ── - const kernelSpawn = this.kernelInstance!.exports.kernel_spawn_process as - (parentPid: number, blobPtr: KernelPointer, blobLen: KernelPointer) => number; - const result = kernelSpawn( - parentPid, - this.toKernelPtr(this.scratchOffset), - this.toKernelPtr(blobLen), - ); - if (result < 0) { - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, (-result) >>> 0); - return; - } - const childPid = result >>> 0; + /** + * Handle SYS_FUTEX directly on process memory. + * + * The kernel's host_futex_wake/wait imports operate on kernel memory, but + * futex addresses are in process memory. We bypass the kernel entirely and + * implement the futex ops here. + * + * FUTEX_WAIT: compare-and-block. If the value at addr matches expected, + * use Atomics.waitAsync to wait for a change, then return 0. If it doesn't + * match, return -EAGAIN. + * + * FUTEX_WAKE: wake up to `val` waiters on addr. Returns number woken. + */ + private handleFutex(channel: ChannelInfo, origArgs: number[]): void { + const addr = origArgs[0]; // uaddr (byte offset in process memory) + const op = origArgs[1]; // futex op (may include PRIVATE flag) + const val = origArgs[2]; // value (expected for WAIT, count for WAKE) + + const FUTEX_PRIVATE_FLAG = 128; + const FUTEX_CLOCK_REALTIME = 256; + const baseOp = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); + + const FUTEX_WAIT = 0; + const FUTEX_WAKE = 1; + const FUTEX_REQUEUE = 3; + const FUTEX_CMP_REQUEUE = 4; + const FUTEX_WAKE_OP = 5; + const FUTEX_WAIT_BITSET = 9; + const FUTEX_WAKE_BITSET = 10; + + const i32View = new Int32Array(channel.memory.buffer); + const index = addr >>> 2; + + if (baseOp === FUTEX_WAIT || baseOp === FUTEX_WAIT_BITSET) { + // Pre-empt cancel: if SYS_THREAD_CANCEL arrived before we got here + // the channel status was PENDING but no futex wait had been set up + // yet, so handleThreadCancel had nothing to notify. Completing the + // syscall with EINTR lets the guest's post-__testcancel() pick up + // the flag and exit. The deferred-cancel guest overlay treats this + // return value like any other EINTR and checks self->cancel. + if (this.pendingCancels.has(channel)) { + this.pendingCancels.delete(channel); + this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); + this.relistenChannel(channel); + return; + } + // Compare value at addr with expected + const currentVal = Atomics.load(i32View, index); + if (currentVal !== val) { + // Value already changed — return -EAGAIN (Linux convention) + this.completeChannelRaw(channel, -EAGAIN, EAGAIN); + this.relistenChannel(channel); + return; + } + + // Read timeout from origArgs[3] (pointer to struct timespec in process memory). + // Layout: { int64 tv_sec; int64 tv_nsec } — 16 bytes, relative timeout. + let timeoutMs: number | undefined; + const timeoutPtr = origArgs[3]; + if (timeoutPtr !== 0) { + const dataView = new DataView(channel.memory.buffer); + const tv_sec = Number(dataView.getBigInt64(timeoutPtr, true)); + const tv_nsec = Number(dataView.getBigInt64(timeoutPtr + 8, true)); + if (tv_sec < 0 || (tv_sec === 0 && tv_nsec <= 0)) { + // Already expired + this.completeChannelRaw(channel, -ETIMEDOUT, ETIMEDOUT); + this.relistenChannel(channel); + return; + } + timeoutMs = tv_sec * 1000 + Math.ceil(tv_nsec / 1_000_000); + if (timeoutMs <= 0) timeoutMs = 1; // minimum 1ms + // Cap to avoid Node.js TimeoutOverflowWarning (max safe is 2^31-1 ms ≈ 24.8 days). + // Without this, huge timeouts from 32-bit LONG_MAX deadlines get clipped to 1ms + // by Node.js, causing tight retry loops. + if (timeoutMs > 2147483647) timeoutMs = 2147483647; + } - // Bump host-side nextChildPid watermark so a subsequent fork() in the - // parent can't collide with the kernel's allocation. - if (childPid >= this.nextChildPid) this.nextChildPid = childPid + 1; + // Value matches — wait asynchronously for it to change + const waitResult = Atomics.waitAsync(i32View, index, val); + if (waitResult.async) { + let settled = false; + let timer: ReturnType | undefined; - // ── Launch the worker async (with already-resolved bytes) ── - this.callbacks.onSpawn!(childPid, programBytes, argv, envp).then((rc) => { - if (rc < 0) { - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as - (pid: number) => number; - removeProcess(childPid); - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, (-rc) >>> 0); - return; - } - // Write the child pid through pid_out_ptr in caller memory. - if (pidOutPtr !== 0) { - new DataView(channel.memory.buffer).setInt32(pidOutPtr, childPid, true); - } - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, 0, 0); - }).catch((err) => { - console.error(`[kernel] spawn error for parent ${parentPid}:`, err); - const removeProcess = this.kernelInstance!.exports.kernel_remove_process as - (pid: number) => number; - removeProcess(childPid); - this.completeChannel(channel, SYS_SPAWN, origArgs, undefined, -1, 5); // EIO - }); - } + const settle = (): boolean => { + if (settled) return false; + settled = true; + if (timer !== undefined) clearTimeout(timer); + this.pendingFutexWaits.delete(channel); + return true; + }; + const complete = (retVal: number, errVal: number) => { + if (!settle()) return; + if (!this.isRegisteredChannel(channel)) return; + this.completeChannelRaw(channel, retVal, errVal); + channel.consecutiveSyscalls = 0; // genuinely blocked — reset + this.relistenChannel(channel); + }; + const wakeAllEngineWaiters = () => { + // waitAsync has no exact-waiter cancellation API. Wake every engine + // waiter on this address so a retired closure cannot remain stale + // and consume a later FUTEX_WAKE(1) quota. Peer wakeups are valid + // spurious futex returns and recheck their predicates. + Atomics.notify(i32View, index); + }; + const interrupt = ( + interruptRetVal: number, + interruptErrVal: number, + ) => { + wakeAllEngineWaiters(); + complete(interruptRetVal, interruptErrVal); + }; + const retire = () => { + wakeAllEngineWaiters(); + settle(); + }; - /** - * Read a null-terminated string from process memory at the given pointer. - */ - private readCStringFromProcess(mem: Uint8Array, ptr: number, maxLen = 4096): string { - if (ptr === 0) return ""; - let len = 0; - while (ptr + len < mem.length && mem[ptr + len] !== 0 && len < maxLen) { - len++; - } - // .slice() copies from SharedArrayBuffer into a regular ArrayBuffer - // because TextDecoder.decode() doesn't accept SharedArrayBuffer views. - return new TextDecoder().decode(mem.slice(ptr, ptr + len)); - } + // Track the wait so SYS_THREAD_CANCEL can force-wake this channel + // without leaving an uncancellable engine waiter behind. + this.pendingFutexWaits.set(channel, { + futexIndex: index, + interrupt, + retire, + }); - /** - * Read a null-terminated array of string pointers from process memory. - * Each element is a 32-bit pointer to a null-terminated string. - */ - private readStringArrayFromProcess(mem: Uint8Array, arrayPtr: number, ptrWidth: 4 | 8 = 4): string[] { - if (arrayPtr === 0) return []; - const result: string[] = []; - const view = new DataView(mem.buffer, mem.byteOffset, mem.byteLength); - for (let i = 0; i < 1024; i++) { - let strPtr: number; - if (ptrWidth === 8) { - strPtr = Number(view.getBigUint64(arrayPtr + i * 8, true)); + waitResult.value.then(() => { + complete(0, 0); + }); + + if (timeoutMs !== undefined) { + timer = setTimeout(() => { + interrupt(-ETIMEDOUT, ETIMEDOUT); + }, timeoutMs); + } } else { - strPtr = view.getUint32(arrayPtr + i * 4, true); + // Already changed — return 0 + this.completeChannelRaw(channel, 0, 0); + this.relistenChannel(channel); } - if (strPtr === 0) break; - result.push(this.readCStringFromProcess(mem, strPtr)); + return; } - return result; - } - - /** - * Handle SYS_EXECVE: read path, argv, and envp from process memory, - * then call the onExec callback to load the new program. - */ - private handleExec(channel: ChannelInfo, origArgs: number[]): void { - const processMem = new Uint8Array(channel.memory.buffer); - // Read path (arg 0), argv (arg 1), envp (arg 2) from process memory - const pw = this.getPtrWidth(channel.pid); - let path = this.readCStringFromProcess(processMem, origArgs[0]); - const argv = this.readStringArrayFromProcess(processMem, origArgs[1], pw); - const envp = this.readStringArrayFromProcess(processMem, origArgs[2], pw); + if (baseOp === FUTEX_WAKE || baseOp === FUTEX_WAKE_BITSET) { + const woken = Atomics.notify(i32View, index, val); + this.completeChannelRaw(channel, woken, 0); + this.relistenChannel(channel); + return; + } - // Resolve relative exec paths against process CWD (not initial KERNEL_CWD). - // Critical for posix_spawn with chdir file actions where child CWD != parent CWD. - if (path && !path.startsWith("/")) { - path = this.resolveExecPathAgainstCwd(channel.pid, path); + if (baseOp === FUTEX_REQUEUE || baseOp === FUTEX_CMP_REQUEUE) { + // Wake val waiters on uaddr, can't truly requeue with Atomics, + // so wake val + val2 on uaddr. + const val2 = origArgs[3]; // timeout param repurposed as val2 + const woken = Atomics.notify(i32View, index, val + val2); + this.completeChannelRaw(channel, woken, 0); + this.relistenChannel(channel); + return; } - if (!this.callbacks.onExec) { - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, 38); // ENOSYS + if (baseOp === FUTEX_WAKE_OP) { + // Wake val waiters on uaddr, then conditionally wake val2 on uaddr2. + // Simplified: just wake both. + const val2 = origArgs[3]; + const uaddr2 = origArgs[4]; + const index2 = uaddr2 >>> 2; + let woken = Atomics.notify(i32View, index, val); + woken += Atomics.notify(i32View, index2, val2); + this.completeChannelRaw(channel, woken, 0); + this.relistenChannel(channel); return; } - // Call the async exec handler FIRST — onExec returns ENOENT early if the - // program doesn't exist, allowing posix_spawnp/execvpe PATH search to retry. - // kernel_exec_setup and prepareProcessForExec are deferred until after - // onExec confirms the program exists (returns 0). - this.callbacks.onExec(channel.pid, path, argv, envp).then((result) => { - if (result < 0) { - // Exec failed (e.g. ENOENT) — process is still alive. - // Complete the channel so the calling process can handle the error - // (e.g., __execvpe tries the next PATH entry). - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, (-result) >>> 0); - } - // On success (result === 0), execve doesn't return — the Worker has been - // reinitialized with the new program via registerProcess. The old channel - // is dead (prepareProcessForExec removed it in onExec). - }).catch((err) => { - console.error(`[kernel] exec error for pid ${channel.pid}:`, err); - this.completeChannel(channel, SYS_EXECVE, origArgs, undefined, -1, 5); // EIO - }); + // Unknown futex op — return -ENOSYS + this.completeChannelRaw(channel, -38, 38); + this.relistenChannel(channel); } /** - * Resolve a relative exec path against the process's kernel CWD. - * Returns absolute path if CWD can be queried, otherwise returns path unchanged. + * Notify the kernel that a thread has exited. + * Removes thread state from the process's thread table. */ - private resolveExecPathAgainstCwd(pid: number, path: string): string { - const getCwd = this.kernelInstance!.exports.kernel_get_cwd as - ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!getCwd) return path; - const cwdLen = getCwd(pid, this.toKernelPtr(this.scratchOffset), 4096); - if (cwdLen <= 0) return path; - const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); - const cwd = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + cwdLen)); - const joined = cwd.endsWith("/") ? cwd + path : cwd + "/" + path; - // Normalize . and .. components (e.g. /data/spawn/./prog → /data/spawn/prog) - const parts = joined.split("/"); - const normalized: string[] = []; - for (const part of parts) { - if (part === "." || part === "") continue; - if (part === ".." && normalized.length > 0) { normalized.pop(); continue; } - normalized.push(part); + notifyThreadExit(pid: number, tid: number): void { + if (!this.kernelInstance) return; + const threadExit = this.kernelInstance.exports.kernel_thread_exit as + ((pid: number, tid: number) => number) | undefined; + if (threadExit) { + threadExit(pid, tid); } - return "/" + normalized.join("/"); } /** - * Handle SYS_EXECVEAT: execveat(dirfd, path, argv, envp, flags). - * Used by fexecve which calls execveat(fd, "", argv, envp, AT_EMPTY_PATH). - * Resolves the fd path via kernel_get_fd_path, then delegates to exec flow. + * Complete kernel-side cleanup for a thread whose worker has stopped. + * Normal pthread exit reaches this from SYS_EXIT. Crash paths use the same + * cleanup so pthread_join waiters do not stay blocked on CLONE_CHILD_CLEARTID. + * + * Both identifiers matter: `channelOffset` removes the host mailbox/fork + * context, while `tid` addresses the kernel/libc thread state and clear-TID + * futex word used by joiners. */ - private handleExecveat(channel: ChannelInfo, origArgs: number[]): void { - const AT_EMPTY_PATH = 0x1000; - const dirfd = origArgs[0]; - const flags = origArgs[4]; - - const processMem = new Uint8Array(channel.memory.buffer); - - // Read path from process memory - const pw = this.getPtrWidth(channel.pid); - const pathStr = this.readCStringFromProcess(processMem, origArgs[1]); - const argv = this.readStringArrayFromProcess(processMem, origArgs[2], pw); - const envp = this.readStringArrayFromProcess(processMem, origArgs[3], pw); - - let execPath: string; + finalizeThreadExit(pid: number, tid: number, channelOffset: number): void { + const tidKey = `${pid}:${channelOffset}`; + this.channelTids.delete(tidKey); + this.threadForkContexts.delete(tidKey); - if ((flags & AT_EMPTY_PATH) !== 0 && pathStr === "") { - // fexecve path: resolve fd to file path via kernel - const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as - ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; - if (!getFdPath) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS - return; - } - const result = getFdPath(channel.pid, dirfd, this.toKernelPtr(this.scratchOffset), 4096); - if (result <= 0) { - const errno = result < 0 ? (-result) >>> 0 : 2; // ENOENT - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, errno); - return; - } - const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); - execPath = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + result)); - } else if (pathStr.startsWith("/")) { - execPath = pathStr; - } else { - // Relative path — let kernel resolve against dirfd/CWD. - // For simplicity, resolve against process CWD here. - // The kernel's sys_execveat already resolves this, but since we intercept - // host-side, we need to do it ourselves. - const getCwd = this.kernelInstance!.exports.kernel_get_cwd as - ((pid: number, bufPtr: number, bufLen: number) => number) | undefined; - if (getCwd) { - const cwdLen = getCwd(channel.pid, this.scratchOffset, 4096); - if (cwdLen > 0) { - const kernelBuf = new Uint8Array(this.kernelMemory!.buffer); - const cwd = new TextDecoder().decode(kernelBuf.slice(this.scratchOffset, this.scratchOffset + cwdLen)); - execPath = cwd.endsWith("/") ? cwd + pathStr : cwd + "/" + pathStr; - } else { - execPath = pathStr; - } - } else { - execPath = pathStr; + const ctidKey = `${pid}:${tid}`; + const ctidPtr = this.threadCtidPtrs.get(ctidKey); + if (ctidPtr && ctidPtr !== 0) { + this.threadCtidPtrs.delete(ctidKey); + const channel = this.activeChannels.find( + (ch) => ch.pid === pid && ch.channelOffset === channelOffset, + ); + const memory = channel?.memory ?? this.processes.get(pid)?.memory; + if (memory) { + const procView = new DataView(memory.buffer); + procView.setInt32(ctidPtr, 0, true); + const i32View = new Int32Array(memory.buffer); + Atomics.notify(i32View, ctidPtr >>> 2, 1); } } - if (!this.callbacks.onExec) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 38); // ENOSYS + this.notifyThreadExit(pid, tid); + this.removeChannel(pid, channelOffset); + } + + /** Queue one host-scheduled expiration through the ABI-required kernel path. */ + private firePosixTimer(pid: number, timerId: number, signum: number): void { + const fire = this.kernelInstance!.exports.kernel_posix_timer_fire as ( + pid: number, + timerId: number, + ) => number; + const targetTid = fire(pid, timerId); + if (targetTid < 0) return; + + if (targetTid > 0) { + this.wakePendingSignalWaits(pid, signum, targetTid); return; } - this.callbacks.onExec(channel.pid, execPath, argv, envp).then((result) => { - if (result < 0) { - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, (-result) >>> 0); - } - }).catch((err) => { - console.error(`[kernel] execveat error for pid ${channel.pid}:`, err); - this.completeChannel(channel, SYS_EXECVEAT, origArgs, undefined, -1, 5); // EIO - }); + this.wakePendingSignalWaits(pid, signum); + this.sendSignalToProcess(pid, signum, false); } - /** - * Handle SYS_CLONE: thread creation. Call the onClone callback to spawn - * a thread Worker sharing the parent's Memory. - */ - private handleClone(channel: ChannelInfo, origArgs: number[]): void { - // Channel args from musl's __clone override which calls kernel_clone directly: - // kernel_clone(fn_ptr, stack_ptr, flags, arg, ptid_ptr, tls_ptr, ctid_ptr) - // The channel syscall path dispatches SYS_CLONE with Linux syscall - // convention: - // a1=flags, a2=stack, a3=ptid, a4=tls, a5=ctid - // The kernel dispatch remaps: kernel_clone(0, a2, a1, 0, a3, a4, a5) - // - // However, programs using the musl overlay's __clone call kernel_clone - // directly as a Wasm import, which means they DON'T go through - // channel_syscall. They use the kernel.kernel_clone import provided - // by buildThreadKernelStubs or the host kernel. So origArgs here - // come from the channel in Linux syscall convention: - // origArgs[0]=flags, [1]=stack, [2]=ptid, [3]=tls, [4]=ctid + /** Wake rt_sigtimedwait callers whose mask accepts this signal. */ + private wakePendingSignalWaits( + pid: number, + signum: number, + targetTid?: number, + ): void { + const matches = Array.from(this.pendingSignalWaits.entries()).filter( + ([, entry]) => { + if (entry.channel.pid !== pid) return false; + if ( + targetTid !== undefined && + this.guestTidForChannel(entry.channel) !== targetTid + ) { + return false; + } + const maskPtr = entry.origArgs[0] >>> 0; + if (maskPtr === 0 || signum <= 0 || signum > 64) return false; + const mask = new DataView( + entry.channel.memory.buffer, + ).getBigUint64(maskPtr, true); + return (mask & (1n << BigInt(signum - 1))) !== 0n; + }, + ); - if (!this.callbacks.onClone) { - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 38); - return; + for (const [key, entry] of matches) { + if (this.pendingSignalWaits.get(key) !== entry) continue; + clearTimeout(entry.timer); + this.pendingSignalWaits.delete(key); + if (this.isRegisteredChannel(entry.channel)) { + this.retrySyscall(entry.channel); + } } + } - // Route through kernel_handle_channel — the kernel allocates a TID and - // stores ThreadInfo. The dispatch table remaps args correctly. - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, SYS_CLONE, true); - for (let i = 0; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(origArgs[i]), true); + private cleanupPendingSignalWaits(pid: number): void { + for (const [key, entry] of this.pendingSignalWaits ?? []) { + if (entry.channel.pid !== pid) continue; + clearTimeout(entry.timer); + this.pendingSignalWaits.delete(key); + this.signalWaitDeadlines?.delete(key); } - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } finally { - this.currentHandlePid = 0; + for (const [key, entry] of this.signalWaitDeadlines ?? []) { + if (entry.pid === pid) this.signalWaitDeadlines.delete(key); } + } - const retVal = Number(kernelView.getBigInt64(CH_RETURN, true)); - const errVal = kernelView.getUint32(CH_ERRNO, true); + /** + * Queue a signal on a target process in the kernel by invoking SYS_KILL + * through kernel_handle_channel. The signal is queued in the kernel's + * ProcessTable and will be delivered via dequeueSignalForDelivery on the + * target process's next syscall completion. + */ + private sendSignalToProcess( + targetPid: number, + signum: number, + queueSignal = true, + ): void { + if (!this.kernelInstance || !this.kernelMemory) return; - if (retVal < 0) { - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, retVal, errVal); - return; + // Do not gate on the host registration map: exec temporarily removes the + // old worker registration while the same kernel Process (and its alarm) + // remains alive. Queuing directly in the ProcessTable prevents a timer + // that expires in that handoff window from being lost. + + if (queueSignal) { + const kernelView = new DataView( + this.kernelMemory.buffer, + this.scratchOffset, + ); + // Write SYS_KILL into scratch: kill(targetPid, signum) + kernelView.setUint32(CH_SYSCALL, SYS_KILL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(targetPid), true); // arg0 = pid + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(signum), true); // arg1 = sig + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + + const handleChannel = this.kernelInstance.exports + .kernel_handle_channel as ( + offset: KernelPointer, + pid: number, + ) => number; + this.currentHandlePid = targetPid; + // Host-originated process signals are shared deliveries. Force tid=0 so + // the kernel does not consult state left over from a prior dispatch. + const setTid = this.kernelInstance.exports.kernel_set_current_tid as + ((tid: number) => void) | undefined; + if (setTid) setTid(0); + try { + handleChannel(this.toKernelPtr(this.scratchOffset), targetPid); + } catch (err) { + // Non-fatal — signal delivery is best-effort from the host side + console.error( + `[sendSignalToProcess] kernel threw for pid=${targetPid} sig=${signum}: ${err}`, + ); + return; + } finally { + this.currentHandlePid = 0; + } } - const tid = retVal; + if (queueSignal) this.wakePendingSignalWaits(targetPid, signum); - // CLONE_PARENT_SETTID: write TID to ptid_ptr in process memory. - // The host writes this because ptid_ptr is in process memory, not kernel - // memory. - const CLONE_PARENT_SETTID = 0x00100000; - const flags = origArgs[0]; - const ptidPtr = origArgs[2]; - if (flags & CLONE_PARENT_SETTID && ptidPtr !== 0) { - const procView = new DataView(channel.memory.buffer); - procView.setInt32(ptidPtr, tid, true); - } + // Signal generation can synchronously stop or continue one or many + // processes. Consume those transition events before any deliverability + // query or blocked-syscall retry observes the target. + this.drainAndProcessWakeupEvents(); - // Read fnPtr and argPtr from the channel's CH_DATA area (written by kernel_clone stub) - // These are always written as u32 by the glue (even on wasm64, table indices are i32) - const processView = new DataView(channel.memory.buffer, channel.channelOffset); - const fnPtr = processView.getUint32(CH_DATA, true); - const argPtr = processView.getUint32(CH_DATA + 4, true); - const stackPtr = origArgs[1]; - const tlsPtr = origArgs[3]; - const ctidPtr = origArgs[4]; + // Default terminating actions are applied inside kernel_handle_channel. + // Retire a newly exited worker before considering any blocking-channel + // wakeup; guest code must not resume after signal death. + this.reapKilledProcessesAfterSyscall(); + if (this.getProcessExitSignal(targetPid) > 0) return; + + // wait4/waitid live outside the generic retry maps. Prefer any matching + // child status, then interrupt only the exact thread selected for this + // caught signal. If it was serviced, the signal has been consumed. + if (this.interruptWaitingChildForSignal(targetPid, signum)) return; + + // Select the exact eligible thread before waking a per-thread blocking + // channel. A process-level "some thread accepts this" answer is not enough: + // waking a different sleeper can complete its nanosleep early while leaving + // the shared signal pending for the intended thread. + const pickSignalTarget = this.kernelInstance!.exports + .kernel_pick_signal_target_tid as + (pid: number, signum: number) => number; + const targetTid = pickSignalTarget(targetPid, signum); + if (targetTid <= 0) return; + + // Ignored and default-ignore signals are consumed inside the kernel. Do + // not shorten a sleep merely because its mask would have accepted a + // signal that is no longer pending. + const threadHasDeliverable = this.kernelInstance!.exports + .kernel_thread_has_deliverable as + (pid: number, tid: number) => number; + if (threadHasDeliverable(targetPid, targetTid) <= 0) return; - // Register the clear-TID pointer before starting the host Worker. A very - // short-lived pthread can reach SYS_EXIT before onClone resolves. - if (ctidPtr !== 0) { - this.threadCtidPtrs.set(`${channel.pid}:${tid}`, ctidPtr); + // Signal is deliverable — wake any blocking syscall for this process + + // 1. Pending sleep (nanosleep, usleep, clock_nanosleep) + const pendingSleepMatch = Array.from(this.pendingSleeps.entries()).find( + ([channel]) => channel.pid === targetPid + && this.guestTidForChannel(channel) === targetTid, + ); + if (pendingSleepMatch) { + const [sleepChannel, pendingSleep] = pendingSleepMatch; + clearTimeout(pendingSleep.timer); + this.pendingSleeps.delete(sleepChannel); + this.completeSleepWithSignalCheck( + pendingSleep.channel, pendingSleep.syscallNr, pendingSleep.origArgs, + pendingSleep.retVal, pendingSleep.errVal, + ); } - this.callbacks.onClone( - channel.pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, channel.memory, - ).then((assignedTid) => { - if (!this.processes.has(channel.pid)) { - if (ctidPtr !== 0) { - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); - } - return; - } - if (assignedTid !== tid && ctidPtr !== 0) { - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); - this.threadCtidPtrs.set(`${channel.pid}:${assignedTid}`, ctidPtr); + // 2. Pending ppoll/poll retry — wake ALL threads for this pid. + // Snapshot-and-skip-if-replaced: retrySyscall runs handleSyscall + // synchronously, and a non-interruptible blocking wait (notably + // accept(), which has no EINTR path) re-inserts the SAME + // exact-channel key via pendingPollRetries.set when it re-parks on + // EAGAIN. JS Map iterators are not snapshots — a deleted-then- + // reinserted key reappears at the tail and the raw for..of would + // revisit it forever, livelocking the whole kernel worker thread. + // Mirror wakeBlockedPoll / wakeAllBlockedRetries. (Regression: + // SIGCHLD to a forking daemon's master parked in accept() — + // e.g. msmtpd delivering WordPress mail — wedged the kernel.) + const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( + ([, e]) => e.channel.pid === targetPid, + ); + for (const [key, pollEntry] of pollMatches) { + if (this.pendingPollRetries.get(key) !== pollEntry) continue; + if (pollEntry.timer) clearTimeout(pollEntry.timer); + this.pendingPollRetries.delete(key); + if (this.processes.has(targetPid)) { + this.retrySyscall(pollEntry.channel); } - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, assignedTid, 0); - }).catch((err) => { - if (ctidPtr !== 0) { - this.threadCtidPtrs.delete(`${channel.pid}:${tid}`); + } + + // 3. Pending select/pselect6 retries (same snapshot rationale). + const selectMatches = Array.from(this.pendingSelectRetries.entries()).filter( + ([, e]) => e.channel.pid === targetPid, + ); + for (const [key, selectEntry] of selectMatches) { + if (this.pendingSelectRetries.get(key) !== selectEntry) continue; + clearTimeout(selectEntry.timer); + clearImmediate(selectEntry.timer); + this.pendingSelectRetries.delete(key); + if (!this.processes.has(targetPid)) continue; + if (selectEntry.syscallNr === SYS_SELECT) { + this.handleSelect(selectEntry.channel, selectEntry.origArgs); + } else { + this.handlePselect6(selectEntry.channel, selectEntry.origArgs); } - console.error(`[kernel-worker] onClone failed: ${err}`); - this.completeChannel(channel, SYS_CLONE, origArgs, undefined, -1, 12); // ENOMEM - }); + } } - /** - * Handle SYS_EXIT/SYS_EXIT_GROUP: notify the kernel and clean up. - * - * For SYS_EXIT from a non-main channel (thread exit): notify kernel, - * remove channel, and let the host terminate the backing Worker. If an - * older host entry has no thread-exit callback, fall back to completing the - * channel for compatibility. - * For SYS_EXIT from main channel or SYS_EXIT_GROUP: current behavior. - */ - private handleExit(channel: ChannelInfo, syscallNr: number, origArgs: number[]): void { - const exitStatus = origArgs[0]; - const registration = this.processes.get(channel.pid); + // ----------------------------------------------------------------------- + // Process memory management + // + // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory + // (memory index 0 in the kernel module). But the + // process runs in a different WebAssembly.Memory. After brk/mmap/mremap + // syscalls, we must grow the process's memory to cover the returned + // addresses — otherwise the process gets "memory access out of bounds". + // ----------------------------------------------------------------------- - // Check if this is a thread exit (non-main channel + SYS_EXIT) - const isMainChannel = registration && registration.channels.length > 0 && - registration.channels[0].channelOffset === channel.channelOffset; + private ensureFixedMmapProcessMemoryCapacity( + channel: ChannelInfo, + origArgs: number[], + ): boolean { + const addr = origArgs[0] >>> 0; + const len = origArgs[1] >>> 0; + const end = addr + len; + if (!Number.isSafeInteger(end) || end < addr) return false; + const before = channel.memory.buffer.byteLength; + if (end <= before) return true; + try { + const ptrWidth = this.processes.get(channel.pid)?.ptrWidth ?? 4; + growMemoryToCover(channel.memory, end, ptrWidth); + if (channel.memory.buffer.byteLength < end) return false; + // Growth appends zero pages and does not overwrite the MAP_FIXED target. + // Rebind only consumers whose cached view was detached by memory.grow. + this.kernel.framebuffers.rebindMemory(channel.pid); + return true; + } catch { + // Memory.grow itself is irreversible if a later step fails, but it never + // mutates the old fixed interval. Capacity failure stays pre-kernel. + return false; + } + } - if (syscallNr === SYS_EXIT && !isMainChannel) { - // Thread exit: finalize kernel-side thread state, ask the host to tear - // down the backing thread Worker (browser + Node both wire onThreadExit), - // then complete the channel. - const tidKey = `${channel.pid}:${channel.channelOffset}`; - const tid = this.channelTids.get(tidKey) ?? 0; - if (tid > 0) this.finalizeThreadExit(channel.pid, tid, channel.channelOffset); - if (tid > 0) { - this.callbacks.onThreadExit?.(channel.pid, tid, channel.channelOffset); + private ensureProcessMemoryCovers( + pid: number, + processMemory: WebAssembly.Memory, + syscallNr: number, + retVal: number, + origArgs: number[], + ): void { + let endAddr = 0; + let mmapAddr = 0; + let mmapLen = 0; + + // MAP_FAILED is -1 (all bits set). For wasm64 processes retVal could be + // a large positive number when interpreted as unsigned, but the kernel + // returns -1 (sign-extended) which JS sees as -1. Use a simple < 0 + // check instead of comparing to a fixed 32-bit constant. + if (syscallNr === SYS_BRK) { + // retVal is the new program break address + if (retVal >= 0) endAddr = retVal; + } else if (syscallNr === SYS_MMAP) { + // retVal is the mapped address, origArgs[1] is the length + if (retVal >= 0) { + mmapAddr = retVal; + mmapLen = origArgs[1]; + endAddr = mmapAddr + mmapLen; + } + } else if (syscallNr === SYS_MREMAP) { + // retVal is the new address, origArgs[2] is the new length + if (retVal >= 0) { + mmapAddr = retVal; + mmapLen = origArgs[2]; + endAddr = mmapAddr + mmapLen; } - // Complete — never merely abandon — the channel on thread exit. This - // flips the status word off CH_PENDING so the exiting guest's in-wasm - // memory.atomic.wait32() on this status word returns and its waiter is - // removed *before* the thread's slot and channel offset are freed and - // reused by a later clone(). Abandoning (leaving status=PENDING with the - // guest still parked) lets that waiter outlive the slot: #830 replaced - // the cooperative worker-shutdown handshake with an immediate - // terminate(), so the dying Worker's agent is not guaranteed gone before - // reuse. The guest wake protocol notifies exactly one waiter - // (memory.atomic.notify(status, 1)); a stale parked waiter from the prior - // thread then steals the reused thread's first-syscall notify, the - // kernel's Atomics.waitAsync never fires, and the new thread wedges - // forever. Observed as: MariaDB's connection-handler thread (cloned when - // it accepts php-fpm's DB connection) never runs its first syscall, so - // the WordPress-over-MariaDB demo never gets a MySQL greeting and hangs. - this.completeChannelRaw(channel, 0, 0); - return; } - // Run the kernel's exit path so it closes all FDs (including pipe - // write ends). kernel_exit calls sys_exit then traps — catch the trap. - { - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, syscallNr, true); - kernelView.setBigInt64(CH_ARGS, BigInt(exitStatus), true); - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); - try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch { - // Expected: kernel_exit traps with unreachable after closing FDs - } finally { - this.currentHandlePid = 0; + const currentBytes = processMemory.buffer.byteLength; + + if (endAddr > 0 && endAddr > currentBytes) { + const ptrWidth = this.processes.get(pid)?.ptrWidth ?? 4; + growMemoryToCover(processMemory, endAddr, ptrWidth); + // Memory.grow detaches any TypedArray bound to the previous SAB. + // Any cached framebuffer view on this pid is now invalid; the + // renderer must rebuild it on the next frame from the new + // Memory.buffer. Idempotent for pids without a binding. + this.kernel.framebuffers.rebindMemory(pid); + } + + // Zero the mmap'd region. Anonymous mmap must return zeroed pages (like + // Linux). The kernel allocates whole pages, so zero the full page-aligned + // region, not just the requested length. On Linux, bytes beyond the + // requested length up to the page boundary are also zeroed. Without this, + // reused addresses contain stale data from previous allocations, + // corrupting musl's malloc metadata (infinite loops / heap corruption). + // + // For mremap, the existing prefix [old_addr, old_addr + old_len) MUST be + // preserved (mremap is content-preserving — that's the contract mallocng's + // realloc relies on). Only zero the *new tail* [old_len, new_len) when the + // mapping grew in place (retVal === old_addr); the move case is handled + // by the memcpy below, which copies the prefix from the old buffer. + if (mmapLen > 0) { + const PAGE_SIZE = 65536; // Wasm page size + const alignedLen = Math.ceil(mmapLen / PAGE_SIZE) * PAGE_SIZE; + const newBytes = processMemory.buffer.byteLength; + let zeroStart = mmapAddr; + const zeroEnd = Math.min(mmapAddr + alignedLen, newBytes); + if (syscallNr === SYS_MREMAP) { + const oldAddr = origArgs[0] >>> 0; + const oldLen = origArgs[1] >>> 0; + if (mmapAddr === oldAddr && oldLen > 0) { + // In-place grow: prefix [oldAddr, oldAddr + oldLen) must remain + // untouched. Only the new tail [oldAddr + oldLen, ...) needs to be + // zeroed. Page-align the start so we don't tear partial-page bytes + // either way. + const oldEndPageAligned = Math.ceil((oldAddr + oldLen) / PAGE_SIZE) * PAGE_SIZE; + zeroStart = Math.max(zeroStart, oldEndPageAligned); + } + // Move case (mmapAddr !== oldAddr): the new region's prefix gets + // overwritten by the memcpy below; zeroing first is harmless and + // matches anonymous-mmap semantics for any tail bytes the memcpy + // doesn't touch. + } + if (zeroStart < zeroEnd) { + new Uint8Array(processMemory.buffer, zeroStart, zeroEnd - zeroStart).fill(0); } } - // Main thread exit or exit_group: record exit status for waitpid, - // queue SIGCHLD to parent, then notify the host callback. - const exitingPid = channel.pid; - // Idempotency: this guard is shared with handleProcessTerminated so a - // SYS_KILL that races a clean SYS_EXIT from the same process doesn't - // produce two SIGCHLDs / two parent wake-ups. Cleared by - // deactivateProcess and registerProcess. - if (this.hostReaped.has(exitingPid)) { - // Already reaped via the kill path — still complete the channel so - // the worker can finish tearing down, but skip the parent-wakeup work. - this.completeChannelRaw(channel, 0, 0); - this.scheduleWakeBlockedRetries(); - if (this.callbacks.onExit) this.callbacks.onExit(exitingPid, exitStatus); - return; + // For a *moving* mremap, restore the user's bytes from old_addr → new_addr. + // The kernel runs in its own Wasm linear memory, so it can't memcpy across + // the process's address space; mallocng's realloc and any other libc + // caller relies on mremap being content-preserving (Linux remaps physical + // pages — same effect, different mechanism). Without this copy, every + // mallocng allocation that crosses MMAP_THRESHOLD (131,052 bytes) on + // grow loses its prefix because mmap_anonymous returns a zeroed region. + // + // Runs after the zero-fill above, so the prefix is overwritten back to + // its original bytes; the tail (new_len > old_len) stays zeroed, matching + // anonymous-mmap semantics. The kernel's munmap of old_addr is metadata + // only — the underlying bytes are still in the process memory and safe + // to read here. + if ( + syscallNr === SYS_MREMAP && + retVal >= 0 && + retVal !== origArgs[0] && + origArgs[0] !== 0 && + origArgs[1] > 0 + ) { + const oldAddr = origArgs[0] >>> 0; + const oldLen = origArgs[1] >>> 0; + const newAddr = retVal >>> 0; + const newLen = origArgs[2] >>> 0; + const copyLen = Math.min(oldLen, newLen); + if (copyLen > 0) { + const buf = processMemory.buffer; + const totalBytes = buf.byteLength; + if (oldAddr + copyLen <= totalBytes && newAddr + copyLen <= totalBytes) { + const src = new Uint8Array(buf, oldAddr, copyLen); + new Uint8Array(buf, newAddr, copyLen).set(src); + } + } } - this.hostReaped.add(exitingPid); - this.notifyParentOfExitedProcess(exitingPid); + } - // Complete the channel so the worker unblocks from Atomics.wait(). - // Without this, the worker stays blocked and Node.js aborts when - // trying to terminate worker threads during process.exit(). - this.completeChannelRaw(channel, 0, 0); + private trackAnonymousSharedMapping( + channel: ChannelInfo, + mapAddr: number, + origArgs: number[], + ): void { + const len = origArgs[1] >>> 0; + if (len === 0) return; + const processMem = new Uint8Array(channel.memory.buffer); + if (mapAddr + len > processMem.length) return; + + const key = `anon:${channel.pid}:${mapAddr}:${this.nextAnonymousSharedBackingId++}`; + const initial = processMem.slice(mapAddr, mapAddr + len); + this.anonymousSharedBackings.set(key, { + key, + bytes: initial.slice(), + refCount: 1, + version: 0, + }); - // Wake any processes blocked on pipe reads/polls — the exiting process's - // FDs were closed by the kernel (sys_exit), so pipes with no remaining - // writers should now return EOF to readers. - this.scheduleWakeBlockedRetries(); + let pidMap = this.sharedMappings.get(channel.pid); + if (!pidMap) { + pidMap = new Map(); + this.sharedMappings.set(channel.pid, pidMap); + } + pidMap.set(mapAddr, { + fd: -1, + fileOffset: 0, + len, + writable: (origArgs[2] & PROT_WRITE) !== 0, + backingKind: "anonymous", + backingKey: key, + snapshot: initial, + seenVersion: 0, + }); + } - if (this.callbacks.onExit) { - this.callbacks.onExit(exitingPid, exitStatus); - } + private synchronizeSharedMemoryForBoundary( + process: Pick, + ): void { + const registration = this.processes?.get(process.pid); + if (registration && registration.memory !== process.memory) return; + if (this.processes && !registration) return; + if ( + (this.sharedMappings?.size ?? 0) === 0 + && (this.shmMappings?.size ?? 0) === 0 + ) return; + this.syncAnonymousSharedMappingsFromProcess(process); + this.syncFileSharedMappingsFromProcess(process); + this.syncSysvShmMappingsFromProcess(process); } /** - * Handle a process that was terminated by a signal while blocking on a - * syscall retry. Rust already owns the Exited state and wait status; the - * host only wakes the parent waiter and terminates the Worker. + * Merge this process's anonymous MAP_SHARED writes into host-owned backings, + * then import the complete authoritative result. `seenVersion` is advanced + * only after both steps, so a stale process that publishes a disjoint write + * cannot accidentally mark unseen peer bytes as observed. */ - private handleProcessTerminated(channel: ChannelInfo): void { - const exitingPid = channel.pid; - // Idempotency guard — both handleExit and reapKilledProcessesAfterSyscall - // can route here for the same pid; do the parent-wakeup work exactly - // once per generation. Cleared by deactivateProcess + registerProcess - // so a recycled pid (currently impossible with monotonic nextChildPid, - // but defensive) starts fresh. - if (this.hostReaped.has(exitingPid)) return; - this.hostReaped.add(exitingPid); - this.notifyParentOfExitedProcess(exitingPid); + private syncAnonymousSharedMappingsFromProcess( + process: Pick, + options: { force?: boolean } = {}, + ): void { + const pidMap = this.sharedMappings?.get(process.pid); + if (!pidMap) return; + const processMem = new Uint8Array(process.memory.buffer); - // Clean up per-process state - this.sharedMappings.delete(exitingPid); + for (const [mapAddr, mapping] of pidMap) { + if (!mapping.backingKey || !mapping.snapshot) continue; + const backing = this.anonymousSharedBackings?.get(mapping.backingKey); + if (!backing || mapAddr + mapping.len > processMem.length) continue; + const wasStale = (mapping.seenVersion ?? 0) !== backing.version; + // A sole current observer can defer scanning its private Wasm memory, + // but a sole *stale* observer must still import a publication made by a + // child or peer before that other mapping detached. + if (!options.force && backing.refCount <= 1 && !wasStale) continue; + + let changed = false; + if (mapping.writable) { + for (let offset = 0; offset < mapping.len; offset += 4096) { + const len = Math.min(4096, mapping.len - offset); + if (!this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + mapping.snapshot, + offset, + len, + )) continue; + if (this.mergeChangedByteRuns( + processMem, + mapAddr + offset, + mapping.snapshot, + offset, + backing.bytes, + mapping.fileOffset + offset, + len, + )) changed = true; + } + } + if (changed) backing.version++; - // Do NOT complete the channel — the worker is blocked on Atomics.wait - // and waking it would cause the C code to continue executing. - // onExit will terminate the worker. - if (this.callbacks.onExit) { - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - const exitStatus = getExitStatus ? getExitStatus(exitingPid) : -1; - this.callbacks.onExit(exitingPid, exitStatus >= 128 ? exitStatus : -1); + // A publisher may itself have been stale. Always reconcile after a + // publication, rather than assigning the new version to a partial view. + if (changed || wasStale) { + const latest = backing.bytes.slice( + mapping.fileOffset, + mapping.fileOffset + mapping.len, + ); + processMem.set(latest, mapAddr); + mapping.snapshot = latest; + } + mapping.seenVersion = backing.version; } } - /** - * Notify the kernel that a host worker for `pid` died asynchronously - * (uncaught wasm trap, instantiation failure, externally terminated - * Worker) WITHOUT going through the normal SYS_EXIT_GROUP path. - * - * Without this, an OOB/instantiation crash leaves the kernel - * believing the process is still alive: any concurrent waitpid in - * the parent then blocks until host destroy. P-06 / K-03 exposed - * this — the child's wasm trapped during _start, the worker - * reported it via `{type:"error"}`, the host posted `stderr` + - * deactivated the process locally, but the kernel never marked the - * pid as a zombie or woke the parent. - * - * Marks the process as signal-terminated in Rust using `signum` (default - * `SIGSEGV` = 11), queues `SIGCHLD` on the parent, and wakes any parked - * `waitpid` / `waitid`. - * - * Idempotent via `hostReaped`: if the kernel already saw a clean - * SYS_EXIT for this pid, this is a no-op (the kernel's exit - * status wins). Host-side cleanup (channel removal, timer - * cancellation) is still the caller's responsibility — call - * `deactivateProcess` after this if the pid is going away. - */ - notifyHostProcessCrashed(pid: number, signum: number = 11 /* SIGSEGV */): void { - if (this.hostReaped.has(pid)) return; - const markSignaled = this.kernelInstance!.exports.kernel_mark_process_signaled as - ((pid: number, signum: number) => number) | undefined; - if (markSignaled && markSignaled(pid, signum) < 0) return; - this.hostReaped.add(pid); - this.notifyParentOfExitedProcess(pid); - this.sharedMappings.delete(pid); + private mapSharedMmapFromFile( + channel: ChannelInfo, + mapAddr: number, + origArgs: number[], + ): FileSharedMmapResult { + if ((origArgs[1] >>> 0) === 0) return { kind: "mapped" }; + const preparation = this.prepareSharedMmapFromFile(channel, origArgs); + if (preparation.kind !== "prepared") return preparation; + return this.registerPreparedSharedMmap( + channel, + mapAddr, + preparation.context, + ); } /** - * After SYS_KILL completes, scan for processes the kernel just marked - * Exited that the host hasn't reaped. Without this, a `kill` of a - * sleeping child (or any process not blocked in poll/select/pipe — those - * are handled by scheduleWakeBlockedRetries) silently reaps the process - * at the kernel level but can leave the host-side blocked wait queue - * asleep — wait4(-1) then blocks forever. - * - * The kernel sets exit_status to 128 + signum for default Terminate - * actions. Anything < 0 means the process is still alive. + * Resolve, retain, verify, and initially load a regular-file backing before + * the kernel mutates the address space. In particular, MAP_FIXED must not + * destroy its old interval and only then discover that host setup failed. */ - private reapKilledProcessesAfterSyscall(): void { - const getExitStatus = this.kernelInstance!.exports - .kernel_get_process_exit_status as ((pid: number) => number) | undefined; - if (!getExitStatus) return; - - // Snapshot the registered pids so we can mutate this.processes safely - // inside the loop (handleProcessTerminated calls onExit which can - // remove entries). - const pids = Array.from(this.processes.keys()); - for (const pid of pids) { - const status = getExitStatus(pid); - if (status < 128) continue; // still alive, or normally exiting via SYS_EXIT - if (this.hostReaped.has(pid)) continue; // already reaped this generation - - // Cancel any pending blocking-syscall timers — the process is gone. - const ps = this.pendingSleeps.get(pid); - if (ps) { clearTimeout(ps.timer); this.pendingSleeps.delete(pid); } + private prepareSharedMmapFromFile( + channel: ChannelInfo, + origArgs: number[], + ): FileSharedMmapPreparationResult { + const fd = origArgs[4]; + const len = origArgs[1] >>> 0; + const pageOffset = origArgs[5]; + const fileOffset = pageOffset * FILE_PAGE_SIZE; + if ( + !Number.isSafeInteger(pageOffset) + || pageOffset < 0 + || !Number.isSafeInteger(fileOffset) + ) return { kind: "error", errno: EINVAL }; + const writable = (origArgs[2] & PROT_WRITE) !== 0; + + const statResult = this.getFdStatForSharedMapping(channel, fd); + if (statResult.kind === "error") return statResult; + const stat = statResult.value; + if ((stat.mode & 0o170000) !== 0o100000) return { kind: "unsupported" }; + if (stat.hostHandle === null) { + // MemFd and synthetic regular files complete fstat inside the kernel, + // so there is no persistent host capability to retain. They need a + // kernel-owned mapping bridge; MAP_PRIVATE keeps its fd-pread path. + return { kind: "error", errno: ENOTSUP }; + } + const accessResult = this.getFdAccessModeForSharedMapping(channel, fd); + if (accessResult.kind === "error") return accessResult; + const accessMode = accessResult.value; + // POSIX file mappings require a readable descriptor. A shared writable + // mapping additionally requires O_RDWR; the kernel's capability export + // confirms that writes reach persistent host storage rather than a device + // or an in-kernel synthetic object. + if (accessMode === O_WRONLY) return { kind: "error", errno: EACCES }; + const writeAllowed = accessMode === O_RDWR + && this.fdSupportsMmapWriteback(channel.pid, fd); + if (writable && !writeAllowed) return { kind: "error", errno: EACCES }; + + const keyResult = this.resolveSharedMmapBackingKey(stat, stat.hostHandle); + if (keyResult.kind === "error") return keyResult; + const key = keyResult.value; + // Preserve the fd's lifetime capability, not merely the initial + // protection. An O_RDWR fd mapped PROT_READ may be upgraded after the fd + // and pathname disappear; its stable handle must already support writes. + const backingResult = this.getOrCreateSharedMmapBacking( + key, + stat, + writeAllowed, + ); + if (backingResult.kind === "error") return backingResult; + const backing = backingResult.value; - const proc = this.processes.get(pid); - const ch = proc?.channels[0]; - // handleProcessTerminated re-checks hostReaped and adds the pid - // itself, so passing through here is idempotent if two reap - // events fire close together. - if (ch) this.handleProcessTerminated(ch); - } + try { + // A sole existing observer normally defers publication to avoid scanning + // its mapping on every syscall. Before another mapping joins, publish + // every existing observer so the new mapping starts from the latest + // shared state rather than the last persisted/cache snapshot. + this.publishSharedMmapBackingObservers(backing); + this.ensureSharedMmapBackingRangeLoaded(backing, fileOffset, len); + } catch (err) { + this.discardUnreferencedSharedMmapBacking(backing); + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + + // Reserve the backing across the kernel call. MAP_FIXED cleanup may drop + // the last old mapping of this same file before the new tracker installs. + backing.refCount++; + + return { + kind: "prepared", + context: { + fd, + fileOffset, + len, + writable, + writeAllowed, + backing, + }, + }; } - /** Track pids the host has already reaped (prevents double-reaping - * when reapKilledProcessesAfterSyscall is called multiple times for - * the same already-Exited process). Cleared when the pid is - * re-allocated by a fresh fork+register. */ - private hostReaped = new Set(); - - /** - * Handle SYS_WAIT4: wait for a child process to exit. - * Args: [pid, wstatus_ptr, options, rusage_ptr] - */ - private handleWaitpid(channel: ChannelInfo, origArgs: number[]): void { - const targetPid = origArgs[0]; // pid argument - const wstatusPtr = origArgs[1]; - const options = origArgs[2] >>> 0; - const parentPid = channel.pid; - - const poll = this.pollWaitableChild(parentPid, targetPid); - if (poll.kind === "error") { - this.completeWaitpid(channel, origArgs, -1, poll.errno); - return; - } - if (poll.kind === "exited") { - this.consumeExitedChild(parentPid, poll.childPid); - this.writeWaitStatus(channel, wstatusPtr, poll.waitStatus); - this.completeWaitpid(channel, origArgs, poll.childPid, 0); - return; - } - if (options & WNOHANG) { - // Non-blocking: no child exited yet - this.completeWaitpid(channel, origArgs, 0, 0); - return; + /** Install metadata after a successful kernel mmap using preflight state. */ + private registerPreparedSharedMmap( + channel: ChannelInfo, + mapAddr: number, + context: PreparedFileSharedMmap, + ): FileSharedMmapResult { + const { fd, fileOffset, len, writable, writeAllowed, backing } = context; + try { + const processMem = new Uint8Array(channel.memory.buffer); + if (mapAddr + len > processMem.length) { + this.releasePreparedSharedMmap(context); + return { kind: "error", errno: EIO }; + } + // Re-read the authoritative cache here rather than storing preflight + // bytes: MAP_FIXED first flushes the replaced interval, which may refer + // to this same backing and advance it after preparation. + const initial = this.readSharedMmapBackingRange(backing, fileOffset, len); + processMem.set(initial, mapAddr); + let pidMap = this.sharedMappings.get(channel.pid); + if (!pidMap) { + pidMap = new Map(); + this.sharedMappings.set(channel.pid, pidMap); + } + // The preflight reservation becomes this mapping's reference. + this.sharedMmapFdCache.set( + this.sharedMmapFdCacheKey(channel.pid, fd), + { backingKey: backing.key }, + ); + pidMap.set(mapAddr, { + fd, + fileOffset, + len, + writable, + writeAllowed, + backingKind: "file", + backingKey: backing.key, + snapshot: initial, + seenVersion: backing.version, + }); + return { kind: "mapped" }; + } catch (err) { + this.releasePreparedSharedMmap(context); + return { kind: "error", errno: this.sharedMmapErrno(err) }; } - - // Blocking wait: defer completion until a child exits - this.waitingForChild.push({ - parentPid, - channel, - origArgs, - pid: targetPid, - options, - syscallNr: SYS_WAIT4, - }); } - private pollWaitableChild(parentPid: number, targetPid: number): WaitPollResult { - const waitPoll = this.kernelInstance!.exports.kernel_wait4_poll as - (parentPid: number, targetPid: number, statusPtr: KernelPointer) => number; - const result = waitPoll(parentPid, targetPid, this.toKernelPtr(this.scratchOffset!)); - if (result > 0) { - const waitStatus = new DataView(this.kernelMemory!.buffer) - .getInt32(this.scratchOffset!, true); - return { kind: "exited", childPid: result, waitStatus }; + /** Resolve a backend-qualified identity from the live handle, never its path. */ + private resolveSharedMmapBackingKey( + stat: SharedMmapFdStat, + handle: number, + ): SharedMmapHostResult { + try { + const key = this.io.fileHandleIdentity?.(handle, stat.dev, stat.ino) ?? null; + return key + ? { kind: "ok", value: key } + : { kind: "error", errno: ENOTSUP }; + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; } - if (result === 0) return { kind: "running" }; - return { kind: "error", errno: (-result) >>> 0 }; - } - - private getParentPid(pid: number): number | undefined { - const getParentPid = this.kernelInstance!.exports.kernel_get_parent_pid as - (pid: number) => number; - const result = getParentPid(pid); - return result > 0 ? result : undefined; - } - - private consumeExitedChild(parentPid: number, childPid: number): void { - const reapChild = this.kernelInstance!.exports.kernel_reap_exited_child as - (parentPid: number, childPid: number) => number; - reapChild(parentPid, childPid); } - private notifyParentOfExitedProcess(pid: number): void { - const parentPid = this.getParentPid(pid); - if (parentPid === undefined) return; - - const hasNoCldWait = this.kernelInstance!.exports - .kernel_has_sa_nocldwait as ((pid: number) => number) | undefined; - const autoReap = hasNoCldWait ? hasNoCldWait(parentPid) === 1 : false; - if (autoReap) { - this.consumeExitedChild(parentPid, pid); - return; + private getFdStatForSharedMapping( + channel: Pick, + fd: number, + ): SharedMmapHostResult { + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const statPtr = this.scratchOffset + CH_DATA; + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + kernelView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Fstat, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(statPtr), true); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - this.sendSignalToProcess(parentPid, SIGCHLD); - this.wakeWaitingParent(parentPid); - } - - /** Write wait status to the wstatus pointer in process memory. */ - private writeWaitStatus(channel: ChannelInfo, wstatusPtr: number, waitStatus: number): void { - if (wstatusPtr !== 0) { - const procView = new DataView(channel.memory.buffer); - procView.setInt32(wstatusPtr, waitStatus, true); + const previousPid = this.currentHandlePid; + let hostHandle: number | null = null; + this.currentHandlePid = channel.pid; + try { + this.bindKernelTidForChannel(channel as ChannelInfo); + hostHandle = this.kernel.withFstatHandleCapture(() => + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid) + ).handle; + } catch { + return { kind: "error", errno: EIO }; + } finally { + this.currentHandlePid = previousPid; + } + if (this.finishSignalTermination(channel as ChannelInfo)) { + return { kind: "error", errno: EINTR_ERRNO }; + } + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const result = Number(resultView.getBigInt64(CH_RETURN, true)); + const errno = resultView.getUint32(CH_ERRNO, true); + if (result !== 0 || errno !== 0) { + return { + kind: "error", + errno: errno || (result < -1 ? -result : EIO), + }; } + + const statView = new DataView(this.kernelMemory!.buffer, statPtr); + const dev = statView.getBigUint64(0, true); + const ino = statView.getBigUint64(8, true); + const mode = statView.getUint32(16, true); + const size64 = statView.getBigUint64(32, true); + return { + kind: "ok", + value: { + dev, + ino, + size: size64 > BigInt(Number.MAX_SAFE_INTEGER) + ? Number.MAX_SAFE_INTEGER + : Number(size64), + mode, + hostHandle, + }, + }; } - /** Complete a waitpid syscall. */ - private completeWaitpid(channel: ChannelInfo, origArgs: number[], retVal: number, errVal: number): void { - // Waitpid is handled host-side (never goes through kernel_handle_channel), - // so we must check for pending signals here. Without this, cross-process - // signals (e.g., kill from child to parent) are lost — the signal is queued - // in the kernel but never dequeued for the blocked parent. - this.dequeueSignalForDelivery(channel); - this.completeChannel(channel, SYS_WAIT4, origArgs, undefined, retVal, errVal); + private getFdPathForSharedMapping( + channel: Pick, + fd: number, + ): SharedMmapHostResult { + const getFdPath = this.kernelInstance!.exports.kernel_get_fd_path as + ((pid: number, fd: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!getFdPath) return { kind: "error", errno: ENOSYS }; + const ptr = this.scratchOffset + CH_DATA; + let len: number; + try { + len = getFdPath( + channel.pid, + fd, + this.toKernelPtr(ptr), + Math.min(4096, CH_DATA_SIZE), + ); + } catch { + return { kind: "error", errno: EIO }; + } + if (len < 0) return { kind: "error", errno: -len }; + if (len === 0) return { kind: "error", errno: ENOENT }; + return { + kind: "ok", + value: new TextDecoder().decode( + new Uint8Array(this.kernelMemory!.buffer).slice(ptr, ptr + len), + ), + }; } - /** Wake a parent blocked in waitpid/waitid when a child exits. */ - private wakeWaitingParent(parentPid: number): void { - let idx = -1; - let poll: WaitPollResult | undefined; - for (let i = 0; i < this.waitingForChild.length; i++) { - const waiter = this.waitingForChild[i]; - if (waiter.parentPid !== parentPid) continue; - const waiterPoll = this.pollWaitableChild(waiter.parentPid, waiter.pid); - if (waiterPoll.kind !== "exited") continue; - idx = i; - poll = waiterPoll; - break; + private getFdAccessModeForSharedMapping( + channel: Pick, + fd: number, + ): SharedMmapHostResult { + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + kernelView.setUint32(CH_SYSCALL, SYS_FCNTL, true); + kernelView.setBigInt64(CH_ARGS, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + CH_ARG_SIZE, BigInt(F_GETFL), true); + for (let i = 2; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); } - if (idx === -1 || poll?.kind !== "exited") return; - - const waiter = this.waitingForChild[idx]; - this.waitingForChild.splice(idx, 1); - if (waiter.syscallNr === SYS_WAITID) { - // waitid: write siginfo_t, optionally consume zombie - this.writeSignalInfo(waiter.channel, waiter.origArgs[2], poll.childPid, poll.waitStatus); - if (!(waiter.options & WNOWAIT)) { - this.consumeExitedChild(parentPid, poll.childPid); - } - this.dequeueSignalForDelivery(waiter.channel); - this.completeChannel(waiter.channel, SYS_WAITID, waiter.origArgs, undefined, 0, 0); - } else { - // wait4: write wstatus, consume zombie - this.consumeExitedChild(parentPid, poll.childPid); - this.writeWaitStatus(waiter.channel, waiter.origArgs[1], poll.waitStatus); - this.completeWaitpid(waiter.channel, waiter.origArgs, poll.childPid, 0); + const previousPid = this.currentHandlePid; + this.currentHandlePid = channel.pid; + try { + this.bindKernelTidForChannel(channel as ChannelInfo); + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + } catch { + return { kind: "error", errno: EIO }; + } finally { + this.currentHandlePid = previousPid; + } + if (this.finishSignalTermination(channel as ChannelInfo)) { + return { kind: "error", errno: EINTR_ERRNO }; + } + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const result = Number(resultView.getBigInt64(CH_RETURN, true)); + const errno = resultView.getUint32(CH_ERRNO, true); + if (result < 0 || errno !== 0) { + return { + kind: "error", + errno: errno || (result < -1 ? -result : EIO), + }; } + return { kind: "ok", value: result & O_ACCMODE }; } - /** - * Re-check deferred waitpid/waitid calls after a process group change. - * When a child changes its pgid (setpgid/setsid), a parent waiting on - * waitpid(-pgid) may no longer have matching children → return ECHILD. - */ - private recheckDeferredWaitpids(): void { - // Iterate backwards to safely splice while iterating - for (let i = this.waitingForChild.length - 1; i >= 0; i--) { - const waiter = this.waitingForChild[i]; - // Only re-check waiters targeting a specific process group (pid < -1 or pid == 0) - if (waiter.pid > 0 || waiter.pid === -1) continue; - - const poll = this.pollWaitableChild(waiter.parentPid, waiter.pid); - if (poll.kind === "error") { - // No more matching children — wake with ECHILD - this.waitingForChild.splice(i, 1); - if (waiter.syscallNr === SYS_WAITID) { - this.completeChannel(waiter.channel, SYS_WAITID, waiter.origArgs, undefined, -1, poll.errno); - } else { - this.completeWaitpid(waiter.channel, waiter.origArgs, -1, poll.errno); + private getOrCreateSharedMmapBacking( + key: string, + source: SharedMmapFdStat, + sourceWritable: boolean, + ): SharedMmapHostResult { + const sourceHandle = source.hostHandle; + if (sourceHandle === null) return { kind: "error", errno: ENOTSUP }; + const existing = this.sharedMmapBackings.get(key); + if (existing) { + if (sourceWritable && !existing.writable) { + // An open description's access mode cannot change. A writable source + // must therefore be a distinct O_RDWR handle for the same file. + if (sourceHandle === existing.handle) { + return { kind: "error", errno: EIO }; + } + try { + this.kernel.retainHostFileHandle(sourceHandle); + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; } + const oldHandle = existing.handle; + existing.handle = sourceHandle; + existing.writable = true; + existing.size = source.size; + existing.sizeValid = true; + this.kernel.releaseHostFileHandle(oldHandle); + } else { + const errno = this.revalidateSharedMmapBacking(existing); + if (errno !== 0) return { kind: "error", errno }; } + return { kind: "ok", value: existing }; } - } - /** - * Handle SYS_WAITID: wait for a child process state change. - * Args: [idtype, id, siginfo_ptr, options, rusage_ptr] - * - * Supports P_PID, P_ALL, P_PGID id types and WNOWAIT/WNOHANG/WEXITED flags. - * Fills siginfo_t in process memory with si_signo, si_code, si_pid, si_uid, si_status. - */ - private handleWaitid(channel: ChannelInfo, origArgs: number[]): void { - const idtype = origArgs[0]; - const id = origArgs[1]; - const siginfoPtr = origArgs[2]; - const options = origArgs[3] >>> 0; - const parentPid = channel.pid; - const waitPid = this.waitidToWaitPid(idtype, id); + try { + this.kernel.retainHostFileHandle(sourceHandle); + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; + } + const backing: SharedMmapBacking = { + key, + handle: sourceHandle, + writable: sourceWritable, + size: source.size, + sizeValid: true, + pages: new Map(), + dirtyPages: new Set(), + refCount: 0, + version: 0, + }; + this.sharedMmapBackings.set(key, backing); + this.invalidateSharedMmapFdCache(); + return { kind: "ok", value: backing }; + } - const poll = this.pollWaitableChild(parentPid, waitPid); - if (poll.kind === "error") { - this.completeChannel(channel, SYS_WAITID, origArgs, undefined, -1, poll.errno); - return; - } - if (poll.kind === "exited") { - this.writeSignalInfo(channel, siginfoPtr, poll.childPid, poll.waitStatus); - if (!(options & WNOWAIT)) { - this.consumeExitedChild(parentPid, poll.childPid); - } - this.completeChannel(channel, SYS_WAITID, origArgs, undefined, 0, 0); - return; + private revalidateSharedMmapBacking(backing: SharedMmapBacking): number { + try { + const stat = this.io.fstat(backing.handle); + if (!Number.isSafeInteger(stat.size) || stat.size < 0) { + backing.sizeValid = false; + return EIO; + } + if ((stat.mode & 0o170000) !== 0o100000) { + backing.sizeValid = false; + return EIO; + } + const actual = this.resolveSharedMmapBackingKey({ + dev: BigInt(stat.dev), + ino: BigInt(stat.ino), + mode: stat.mode, + size: stat.size, + hostHandle: backing.handle, + }, backing.handle); + if (actual.kind === "error" || actual.value !== backing.key) { + backing.sizeValid = false; + return actual.kind === "error" ? actual.errno : EIO; + } + backing.size = stat.size; + backing.sizeValid = true; + return 0; + } catch (err) { + backing.sizeValid = false; + return this.sharedMmapErrno(err); } + } - if (options & WNOHANG) { - // Zero out siginfo to indicate no child changed state yet - if (siginfoPtr !== 0) { - const procView = new DataView(channel.memory.buffer); - procView.setInt32(siginfoPtr, 0, true); // si_signo = 0 - procView.setInt32(siginfoPtr + 12, 0, true); // si_pid = 0 + private sharedMmapErrno(err: unknown): number { + const mapped = negErrno(err); + return mapped < 0 ? -mapped : mapped || EIO; + } + + private discardUnreferencedSharedMmapBacking(backing: SharedMmapBacking): void { + if (backing.refCount !== 0 || this.sharedMmapBackings.get(backing.key) !== backing) return; + // A zero-reference backing can remain after a failed final writeback. + // Preserve its dirty pages and stable handle for a later same-object map + // rather than silently discarding acknowledged MAP_SHARED stores. + if (backing.dirtyPages.size > 0) return; + this.kernel.releaseHostFileHandle(backing.handle); + this.sharedMmapBackings.delete(backing.key); + this.invalidateSharedMmapFdCache(); + } + + private ensureSharedMmapBackingRangeLoaded( + backing: SharedMmapBacking, + offset: number, + len: number, + ): void { + if (len <= 0) return; + const firstPage = Math.floor(offset / FILE_PAGE_SIZE); + const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); + for (let page = firstPage; page <= lastPage; page++) { + this.ensureSharedMmapBackingPageLoaded(backing, page); + } + } + + private ensureSharedMmapBackingPageLoaded( + backing: SharedMmapBacking, + page: number, + ): Uint8Array { + const existing = backing.pages.get(page); + if (existing) return existing; + if (!backing.sizeValid) { + const errno = this.revalidateSharedMmapBacking(backing); + if (errno !== 0) { + const err = new Error("Cannot determine MAP_SHARED backing size") as + Error & { code: number }; + err.code = errno; + throw err; + } + } + const loaded = this.readSharedMmapBackingPage(backing, page); + backing.pages.set(page, loaded); + return loaded; + } + + private readSharedMmapBackingPage(backing: SharedMmapBacking, page: number): Uint8Array { + const bytes = new Uint8Array(FILE_PAGE_SIZE); + if (!backing.sizeValid) throw new Error("Unknown MAP_SHARED backing size"); + const pageOffset = page * FILE_PAGE_SIZE; + const readable = Math.max(0, Math.min(FILE_PAGE_SIZE, backing.size - pageOffset)); + if (readable === 0) return bytes; + let total = 0; + while (total < readable) { + const remaining = readable - total; + const read = this.io.read( + backing.handle, + bytes.subarray(total), + pageOffset + total, + remaining, + ); + if (read <= 0 || read > remaining) { + // fstat declared these bytes readable. A premature EOF means the file + // raced with this snapshot (or the backend violated count semantics), + // so zero-filling would manufacture data and must fail coherently. + throw new Error(`Invalid MAP_SHARED backing read length: ${read}`); } - this.completeChannel(channel, SYS_WAITID, origArgs, undefined, 0, 0); - return; + total += read; } - - // Blocking wait: defer until a child exits. - this.waitingForChild.push({ - parentPid, - channel, - origArgs, - pid: waitPid, - options, - syscallNr: SYS_WAITID, - }); + return bytes; } - private waitidToWaitPid(idtype: number, id: number): number { - if (idtype === P_PID) return id; - if (idtype === P_PGID) return id === 0 ? 0 : -id; - return -1; + private readSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): Uint8Array { + const result = new Uint8Array(len); + let copied = 0; + while (copied < len) { + const absolute = offset + copied; + const page = Math.floor(absolute / FILE_PAGE_SIZE); + const pageOffset = absolute % FILE_PAGE_SIZE; + const count = Math.min(FILE_PAGE_SIZE - pageOffset, len - copied); + result.set( + this.ensureSharedMmapBackingPageLoaded(backing, page) + .subarray(pageOffset, pageOffset + count), + copied, + ); + copied += count; + } + return result; } - /** - * Write siginfo_t fields for waitid into process memory. - * Layout (wasm32): si_signo(+0), si_errno(+4), si_code(+8), - * si_pid(+12), si_uid(+16), si_status(+20) - */ - private writeSignalInfo(channel: ChannelInfo, siginfoPtr: number, childPid: number, waitStatus: number): void { - if (siginfoPtr === 0) return; - const procView = new DataView(channel.memory.buffer); - // Zero out the full 128-byte siginfo_t first - for (let i = 0; i < 128; i += 4) { - procView.setInt32(siginfoPtr + i, 0, true); + private copyRangeToSharedMmapBacking( + backing: SharedMmapBacking, + offset: number, + bytes: Uint8Array, + markDirty: boolean, + ): void { + let copied = 0; + while (copied < bytes.length) { + const absolute = offset + copied; + const page = Math.floor(absolute / FILE_PAGE_SIZE); + const pageOffset = absolute % FILE_PAGE_SIZE; + const count = Math.min(FILE_PAGE_SIZE - pageOffset, bytes.length - copied); + const wasDirty = backing.dirtyPages.has(page); + this.ensureSharedMmapBackingPageLoaded(backing, page).set( + bytes.subarray(copied, copied + count), + pageOffset, + ); + if (markDirty) backing.dirtyPages.add(page); + else if (!wasDirty) backing.dirtyPages.delete(page); + copied += count; } + } - const signaled = (waitStatus & 0x7f) !== 0; - procView.setInt32(siginfoPtr + 0, SIGCHLD, true); // si_signo - procView.setInt32(siginfoPtr + 4, 0, true); // si_errno - if (signaled) { - procView.setInt32(siginfoPtr + 8, CLD_KILLED, true); // si_code - } else { - procView.setInt32(siginfoPtr + 8, CLD_EXITED, true); // si_code + private syncFileSharedMappingsFromProcess( + process: Pick, + options: { force?: boolean } = {}, + ): void { + const mappings = this.sharedMappings?.get(process.pid); + if (!mappings) return; + const processMem = new Uint8Array(process.memory.buffer); + const candidates: Array<{ + mapAddr: number; + mapping: SharedMmapMapping; + backing: SharedMmapBacking; + snapshot: Uint8Array; + }> = []; + + for (const [mapAddr, mapping] of mappings) { + if (mapping.backingKind !== "file" || !mapping.backingKey || !mapping.snapshot) continue; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing || mapAddr + mapping.len > processMem.length) continue; + const wasStale = (mapping.seenVersion ?? 0) !== backing.version; + if (!options.force && backing.refCount <= 1 && !wasStale) continue; + candidates.push({ mapAddr, mapping, backing, snapshot: mapping.snapshot }); + } + + // Publish every alias before refreshing any alias. A one-pass + // publish-and-refresh loop can leave an earlier alias stale when a later + // alias advances the same backing during this boundary. + for (const { mapAddr, mapping, backing, snapshot } of candidates) { + let changed = false; + if (mapping.writable) { + for (let offset = 0; offset < mapping.len; offset += FILE_PAGE_SIZE) { + const len = Math.min(FILE_PAGE_SIZE, mapping.len - offset); + if (!this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + snapshot, + offset, + len, + )) continue; + if (this.mergeChangedFileMappingRuns( + backing, + processMem, + mapAddr + offset, + snapshot, + offset, + mapping.fileOffset + offset, + len, + )) changed = true; + } + } + if (changed) backing.version++; + } + + // Read every final snapshot before mutating process memory. If one backing + // becomes unreadable, this boundary fails without partially refreshing a + // subset of the process's aliases. + const refreshes = candidates + .filter(({ mapping, backing }) => + (mapping.seenVersion ?? 0) !== backing.version) + .map(({ mapAddr, mapping, backing }) => ({ + mapAddr, + mapping, + backing, + latest: this.readSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + ), + })); + for (const { mapAddr, mapping, backing, latest } of refreshes) { + processMem.set(latest, mapAddr); + mapping.snapshot = latest; + mapping.seenVersion = backing.version; + } + } + + /** Force all current mappings to publish before a new observer or fd read. */ + private publishSharedMmapBackingObservers(backing: SharedMmapBacking): void { + if (backing.refCount <= 0) return; + const observerPids = new Set(); + for (const [pid, mappings] of this.sharedMappings) { + for (const mapping of mappings.values()) { + if (mapping.backingKind === "file" && mapping.backingKey === backing.key) { + observerPids.add(pid); + break; + } + } } - procView.setInt32(siginfoPtr + 12, childPid, true); // si_pid - procView.setInt32(siginfoPtr + 16, 1000, true); // si_uid - if (signaled) { - procView.setInt32(siginfoPtr + 20, waitStatus & 0x7f, true); // si_status = signal number - } else { - procView.setInt32(siginfoPtr + 20, (waitStatus >> 8) & 0xff, true); // si_status = exit code + for (const pid of observerPids) { + const registration = this.processes.get(pid); + if (!registration) { + throw new Error(`Missing process memory for MAP_SHARED observer ${pid}`); + } + this.syncFileSharedMappingsFromProcess(registration, { force: true }); } } - /** - * Handle SYS_FUTEX directly on process memory. - * - * The kernel's host_futex_wake/wait imports operate on kernel memory, but - * futex addresses are in process memory. We bypass the kernel entirely and - * implement the futex ops here. - * - * FUTEX_WAIT: compare-and-block. If the value at addr matches expected, - * use Atomics.waitAsync to wait for a change, then return 0. If it doesn't - * match, return -EAGAIN. - * - * FUTEX_WAKE: wake up to `val` waiters on addr. Returns number woken. - */ - private handleFutex(channel: ChannelInfo, origArgs: number[]): void { - const addr = origArgs[0]; // uaddr (byte offset in process memory) - const op = origArgs[1]; // futex op (may include PRIVATE flag) - const val = origArgs[2]; // value (expected for WAIT, count for WAKE) - - const FUTEX_PRIVATE_FLAG = 128; - const FUTEX_CLOCK_REALTIME = 256; - const baseOp = op & ~(FUTEX_PRIVATE_FLAG | FUTEX_CLOCK_REALTIME); - - const FUTEX_WAIT = 0; - const FUTEX_WAKE = 1; - const FUTEX_REQUEUE = 3; - const FUTEX_CMP_REQUEUE = 4; - const FUTEX_WAKE_OP = 5; - const FUTEX_WAIT_BITSET = 9; - const FUTEX_WAKE_BITSET = 10; - - const i32View = new Int32Array(channel.memory.buffer); - const index = addr >>> 2; + private mergeChangedFileMappingRuns( + backing: SharedMmapBacking, + source: Uint8Array, + sourceOffset: number, + snapshot: Uint8Array, + snapshotOffset: number, + backingOffset: number, + len: number, + ): boolean { + let changed = false; + let i = 0; + while (i < len) { + while (i < len && source[sourceOffset + i] === snapshot[snapshotOffset + i]) i++; + if (i >= len) break; + const start = i; + do { i++; } while ( + i < len && source[sourceOffset + i] !== snapshot[snapshotOffset + i] + ); + this.copyRangeToSharedMmapBacking( + backing, + backingOffset + start, + source.subarray(sourceOffset + start, sourceOffset + i), + true, + ); + changed = true; + } + return changed; + } - if (baseOp === FUTEX_WAIT || baseOp === FUTEX_WAIT_BITSET) { - // Pre-empt cancel: if SYS_THREAD_CANCEL arrived before we got here - // the channel status was PENDING but no futex wait had been set up - // yet, so handleThreadCancel had nothing to notify. Completing the - // syscall with EINTR lets the guest's post-__testcancel() pick up - // the flag and exit. The deferred-cancel guest overlay treats this - // return value like any other EINTR and checks self->cancel. - if (this.pendingCancels.has(channel.channelOffset)) { - this.pendingCancels.delete(channel.channelOffset); - this.completeChannelRaw(channel, -EINTR_ERRNO, EINTR_ERRNO); - this.relistenChannel(channel); - return; + private flushSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): boolean { + if (len <= 0 || backing.dirtyPages.size === 0) return true; + if (!backing.sizeValid) return false; + const requestedEnd = offset + len; + const end = Math.min(requestedEnd, backing.size); + let success = true; + for (const page of Array.from(backing.dirtyPages).sort((a, b) => a - b)) { + const pageStart = page * FILE_PAGE_SIZE; + const pageEnd = pageStart + FILE_PAGE_SIZE; + if (pageStart >= backing.size) { + // Writes beyond EOF are not permitted to grow a mapped file. Drop the + // unrepresentable dirty bytes when this flush covers their page. + if (pageStart < requestedEnd && pageEnd > offset) { + backing.dirtyPages.delete(page); + } + continue; + } + if (pageStart >= end || pageEnd <= offset) continue; + const writeStart = Math.max(offset, pageStart); + const validPageEnd = Math.min(pageEnd, backing.size); + const writeEnd = Math.min(end, validPageEnd); + const source = this.ensureSharedMmapBackingPageLoaded(backing, page).subarray( + writeStart - pageStart, + writeEnd - pageStart, + ); + if (!this.writeAllToSharedMmapBacking(backing, source, writeStart)) { + success = false; + continue; } - // Compare value at addr with expected - const currentVal = Atomics.load(i32View, index); - if (currentVal !== val) { - // Value already changed — return -EAGAIN (Linux convention) - this.completeChannelRaw(channel, -EAGAIN, EAGAIN); - this.relistenChannel(channel); - return; + if (writeStart === pageStart && writeEnd === validPageEnd) { + backing.dirtyPages.delete(page); } + } + return success; + } - // Read timeout from origArgs[3] (pointer to struct timespec in process memory). - // Layout: { int64 tv_sec; int64 tv_nsec } — 16 bytes, relative timeout. - let timeoutMs: number | undefined; - const timeoutPtr = origArgs[3]; - if (timeoutPtr !== 0) { - const dataView = new DataView(channel.memory.buffer); - const tv_sec = Number(dataView.getBigInt64(timeoutPtr, true)); - const tv_nsec = Number(dataView.getBigInt64(timeoutPtr + 8, true)); - if (tv_sec < 0 || (tv_sec === 0 && tv_nsec <= 0)) { - // Already expired - this.completeChannelRaw(channel, -ETIMEDOUT, ETIMEDOUT); - this.relistenChannel(channel); - return; - } - timeoutMs = tv_sec * 1000 + Math.ceil(tv_nsec / 1_000_000); - if (timeoutMs <= 0) timeoutMs = 1; // minimum 1ms - // Cap to avoid Node.js TimeoutOverflowWarning (max safe is 2^31-1 ms ≈ 24.8 days). - // Without this, huge timeouts from 32-bit LONG_MAX deadlines get clipped to 1ms - // by Node.js, causing tight retry loops. - if (timeoutMs > 2147483647) timeoutMs = 2147483647; + private writeAllToSharedMmapBacking( + backing: SharedMmapBacking, + source: Uint8Array, + offset: number, + ): boolean { + let written = 0; + while (written < source.length) { + try { + const count = this.io.write( + backing.handle, + source.subarray(written), + offset + written, + source.length - written, + ); + if (count <= 0) return false; + written += count; + } catch { + return false; } + } + return true; + } - // Value matches — wait asynchronously for it to change - const waitResult = Atomics.waitAsync(i32View, index, val); - if (waitResult.async) { - let settled = false; - let timer: ReturnType | undefined; - - const complete = (retVal: number, errVal: number) => { - if (settled) return; - settled = true; - if (timer !== undefined) clearTimeout(timer); - this.pendingFutexWaits.delete(channel.channelOffset); - if (!this.processes.has(channel.pid)) return; - this.completeChannelRaw(channel, retVal, errVal); - channel.consecutiveSyscalls = 0; // genuinely blocked — reset - this.relistenChannel(channel); - }; - - // Track the wait so SYS_THREAD_CANCEL can force-wake this channel - // by firing Atomics.notify on the futex address. The waitAsync - // Promise resolves naturally and complete() runs above. - this.pendingFutexWaits.set(channel.channelOffset, { channel, futexIndex: index }); - - waitResult.value.then(() => { - complete(0, 0); - }); - - if (timeoutMs !== undefined) { - timer = setTimeout(() => { - // Wake ourselves to break out of the Atomics.waitAsync - Atomics.notify(i32View, index, 1); - complete(-ETIMEDOUT, ETIMEDOUT); - }, timeoutMs); - } - } else { - // Already changed — return 0 - this.completeChannelRaw(channel, 0, 0); - this.relistenChannel(channel); + private flushSharedMappingsBeforeFileSyscall( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + ): boolean { + if ((this.sharedMmapBackings?.size ?? 0) === 0) return true; + try { + if (syscallNr === SYS_TRUNCATE) { + const path = this.resolveSharedMmapPath(channel, origArgs[0]); + return path.kind === "error" + || this.flushSharedBackingForPath(path.value); + } + if (syscallNr === SYS_OPEN || syscallNr === SYS_OPENAT) { + const flags = syscallNr === SYS_OPEN ? origArgs[1] : origArgs[2]; + if ((flags & O_TRUNC) !== 0) { + const path = this.resolveSharedMmapPath( + channel, + syscallNr === SYS_OPEN ? origArgs[0] : origArgs[1], + syscallNr === SYS_OPENAT ? origArgs[0] : AT_FDCWD, + ); + return path.kind === "error" + || this.flushSharedBackingForPath(path.value); + } } - return; + if ( + syscallNr === SYS_MMAP + && (origArgs[3] & MAP_SHARED) === 0 + && (origArgs[3] & MAP_ANONYMOUS) === 0 + && origArgs[4] >= 0 + ) { + // MAP_PRIVATE is populated through the guest fd's pread path after the + // kernel reserves memory. Publish and persist any dirty shared view of + // that file first so the private snapshot does not start stale. + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[4]); + } + if (syscallNr === SYS_SENDFILE) { + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[0]) + && this.flushSharedBackingForFd(channel, origArgs[1]); + } + if (syscallNr === SYS_COPY_FILE_RANGE || syscallNr === SYS_SPLICE) { + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[0]) + && this.flushSharedBackingForFd(channel, origArgs[2]); + } + if (!this.syscallTouchesFdStorageBeforeKernel(syscallNr)) return true; + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + return this.flushSharedBackingForFd(channel, origArgs[0]); + } catch { + return false; } + } - if (baseOp === FUTEX_WAKE || baseOp === FUTEX_WAKE_BITSET) { - const woken = Atomics.notify(i32View, index, val); - this.completeChannelRaw(channel, woken, 0); - this.relistenChannel(channel); - return; + private syscallTouchesFdStorageBeforeKernel(syscallNr: number): boolean { + return syscallNr === SYS_READ + || syscallNr === SYS_PREAD + || syscallNr === SYS_READV + || syscallNr === SYS_PREADV + || syscallNr === SYS_WRITE + || syscallNr === SYS_PWRITE + || syscallNr === SYS_WRITEV + || syscallNr === SYS_PWRITEV + || syscallNr === SYS_FSYNC + || syscallNr === SYS_FDATASYNC + || syscallNr === SYS_FTRUNCATE + || syscallNr === SYS_FALLOCATE; + } + + private flushSharedBackingForFd(channel: ChannelInfo, fd: number): boolean { + if (fd < 0) return true; + const backing = this.findSharedMmapBackingForFd(channel, fd); + if (!backing) return true; + this.publishSharedMmapBackingObservers(backing); + const flushed = this.flushSharedMmapBackingRange( + backing, + 0, + Number.MAX_SAFE_INTEGER, + ); + if (flushed && backing.refCount === 0) { + this.discardUnreferencedSharedMmapBacking(backing); } + return flushed; + } - if (baseOp === FUTEX_REQUEUE || baseOp === FUTEX_CMP_REQUEUE) { - // Wake val waiters on uaddr, can't truly requeue with Atomics, - // so wake val + val2 on uaddr. - const val2 = origArgs[3]; // timeout param repurposed as val2 - const woken = Atomics.notify(i32View, index, val + val2); - this.completeChannelRaw(channel, woken, 0); - this.relistenChannel(channel); - return; + private resolveSharedMmapPath( + channel: Pick, + pathPtr: number, + dirfd: number = AT_FDCWD, + ): SharedMmapHostResult { + try { + const memory = new Uint8Array(channel.memory.buffer); + if (pathPtr <= 0 || pathPtr >= memory.length) { + return { kind: "error", errno: EFAULT }; + } + const limit = Math.min(memory.length, pathPtr + 4096); + let end = pathPtr; + while (end < limit && memory[end] !== 0) end++; + if (end === limit) return { kind: "error", errno: ENAMETOOLONG }; + // Chrome's TextDecoder rejects views backed by SharedArrayBuffer. Copy + // the guest pathname into ordinary host memory before decoding. + const pathBytes = new Uint8Array(end - pathPtr); + pathBytes.set(memory.subarray(pathPtr, end)); + const path = new TextDecoder().decode(pathBytes); + if (!path) return { kind: "error", errno: ENOENT }; + if (path.startsWith("/")) { + return { kind: "ok", value: this.normalizeSharedMmapPath(path) }; + } + + let base: string; + if (dirfd !== AT_FDCWD) { + const baseResult = this.getFdPathForSharedMapping(channel, dirfd); + if (baseResult.kind === "error") return baseResult; + base = baseResult.value; + } else { + const getCwd = this.kernelInstance!.exports.kernel_get_cwd as + ((pid: number, bufPtr: KernelPointer, bufLen: number) => number) | undefined; + if (!getCwd) return { kind: "error", errno: ENOSYS }; + const cwdLen = getCwd( + channel.pid, + this.toKernelPtr(this.scratchOffset), + Math.min(4096, CH_DATA_SIZE), + ); + if (cwdLen < 0) return { kind: "error", errno: -cwdLen }; + if (cwdLen === 0) return { kind: "error", errno: ENOENT }; + base = new TextDecoder().decode( + new Uint8Array(this.kernelMemory!.buffer) + .slice(this.scratchOffset, this.scratchOffset + cwdLen), + ); + } + return { + kind: "ok", + value: this.normalizeSharedMmapPath(`${base}/${path}`), + }; + } catch (err) { + return { kind: "error", errno: this.sharedMmapErrno(err) }; } + } - if (baseOp === FUTEX_WAKE_OP) { - // Wake val waiters on uaddr, then conditionally wake val2 on uaddr2. - // Simplified: just wake both. - const val2 = origArgs[3]; - const uaddr2 = origArgs[4]; - const index2 = uaddr2 >>> 2; - let woken = Atomics.notify(i32View, index, val); - woken += Atomics.notify(i32View, index2, val2); - this.completeChannelRaw(channel, woken, 0); - this.relistenChannel(channel); - return; + private normalizeSharedMmapPath(path: string): string { + const normalized: string[] = []; + for (const component of path.split("/")) { + if (!component || component === ".") continue; + if (component === "..") normalized.pop(); + else normalized.push(component); } - - // Unknown futex op — return -ENOSYS - this.completeChannelRaw(channel, -38, 38); - this.relistenChannel(channel); + return `/${normalized.join("/")}`; } - /** - * Notify the kernel that a thread has exited. - * Removes thread state from the process's thread table. - */ - notifyThreadExit(pid: number, tid: number): void { - if (!this.kernelInstance) return; - const threadExit = this.kernelInstance.exports.kernel_thread_exit as - ((pid: number, tid: number) => number) | undefined; - if (threadExit) { - threadExit(pid, tid); + private findSharedMmapBackingForPath(path: string): SharedMmapBacking | null { + if (this.sharedMmapBackings.size === 0) return null; + try { + const stat = this.io.stat(path); + if ((stat.mode & 0o170000) !== 0o100000) return null; + const key = this.io.fileIdentity?.( + path, + BigInt(stat.dev), + BigInt(stat.ino), + ) ?? null; + return key ? this.sharedMmapBackings.get(key) ?? null : null; + } catch { + // The kernel remains authoritative for the pathname error. If the path + // cannot identify an existing backing, there is nothing safe to flush. + return null; } } - /** - * Complete kernel-side cleanup for a thread whose worker has stopped. - * Normal pthread exit reaches this from SYS_EXIT. Crash paths use the same - * cleanup so pthread_join waiters do not stay blocked on CLONE_CHILD_CLEARTID. - * - * Both identifiers matter: `channelOffset` removes the host mailbox/fork - * context, while `tid` addresses the kernel/libc thread state and clear-TID - * futex word used by joiners. - */ - finalizeThreadExit(pid: number, tid: number, channelOffset: number): void { - const tidKey = `${pid}:${channelOffset}`; - this.channelTids.delete(tidKey); - this.threadForkContexts.delete(tidKey); + private flushSharedBackingForPath(path: string): boolean { + const backing = this.findSharedMmapBackingForPath(path); + if (!backing) return true; + this.publishSharedMmapBackingObservers(backing); + const flushed = this.flushSharedMmapBackingRange( + backing, + 0, + Number.MAX_SAFE_INTEGER, + ); + if (flushed && backing.refCount === 0) { + this.discardUnreferencedSharedMmapBacking(backing); + } + return flushed; + } - const ctidKey = `${pid}:${tid}`; - const ctidPtr = this.threadCtidPtrs.get(ctidKey); - if (ctidPtr && ctidPtr !== 0) { - this.threadCtidPtrs.delete(ctidKey); - const channel = this.activeChannels.find( - (ch) => ch.pid === pid && ch.channelOffset === channelOffset, + private handleSharedMappingsAfterFileSyscall( + channel: ChannelInfo, + syscallNr: number, + origArgs: number[], + retVal: number, + errVal: number, + ): void { + if ((this.sharedMmapBackings?.size ?? 0) === 0) return; + if (errVal !== 0) return; + if ((syscallNr === SYS_OPEN || syscallNr === SYS_OPENAT) && retVal >= 0) { + this.invalidateSharedMmapFdCache(channel.pid, retVal); + const flags = syscallNr === SYS_OPEN ? origArgs[1] : origArgs[2]; + if ((flags & O_TRUNC) !== 0) { + this.reloadSharedMmapBackingForFd(channel, retVal, 0); + } + return; + } + if (syscallNr === SYS_CLOSE && retVal === 0) { + this.invalidateSharedMmapFdCache(channel.pid, origArgs[0]); + return; + } + if (syscallNr === SYS_DUP && retVal >= 0) { + this.invalidateSharedMmapFdCache(channel.pid, retVal); + return; + } + if ((syscallNr === SYS_DUP2 || syscallNr === SYS_DUP3) && retVal >= 0) { + this.invalidateSharedMmapFdCache(channel.pid, origArgs[1]); + return; + } + if (syscallNr === SYS_FCNTL && retVal >= 0) { + const cmd = origArgs[1] >>> 0; + if (cmd === F_DUPFD || cmd === F_DUPFD_CLOEXEC || cmd === F_DUPFD_CLOFORK) { + this.invalidateSharedMmapFdCache(channel.pid, retVal); + return; + } + } + if (syscallNr === SYS_PWRITE && retVal > 0) { + this.updateSharedMmapBackingFromProcessBuffer( + channel, + origArgs[0], + origArgs[1] >>> 0, + retVal, + origArgs[3], ); - const memory = channel?.memory ?? this.processes.get(pid)?.memory; - if (memory) { - const procView = new DataView(memory.buffer); - procView.setInt32(ctidPtr, 0, true); - const i32View = new Int32Array(memory.buffer); - Atomics.notify(i32View, ctidPtr >>> 2, 1); + return; + } + if (syscallNr === SYS_WRITE && retVal > 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if ((syscallNr === SYS_WRITEV || syscallNr === SYS_PWRITEV) && retVal > 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if (syscallNr === SYS_SENDFILE && retVal > 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if ( + (syscallNr === SYS_COPY_FILE_RANGE || syscallNr === SYS_SPLICE) + && retVal > 0 + ) { + this.reloadSharedMmapBackingForFd(channel, origArgs[2]); + return; + } + if (syscallNr === SYS_FTRUNCATE && retVal === 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0], origArgs[1]); + return; + } + if (syscallNr === SYS_FALLOCATE && retVal === 0) { + this.reloadSharedMmapBackingForFd(channel, origArgs[0]); + return; + } + if (syscallNr === SYS_TRUNCATE && retVal === 0) { + const path = this.resolveSharedMmapPath(channel, origArgs[0]); + if (path.kind === "ok") { + this.reloadSharedMmapBackingForPath(path.value, origArgs[1]); } } - - this.notifyThreadExit(pid, tid); - this.removeChannel(pid, channelOffset); } - /** - * Queue a signal on a target process in the kernel by invoking SYS_KILL - * through kernel_handle_channel. The signal is queued in the kernel's - * ProcessTable and will be delivered via dequeueSignalForDelivery on the - * target process's next syscall completion. - */ - private sendSignalToProcess(targetPid: number, signum: number): void { - if (!this.kernelInstance || !this.kernelMemory) return; - - // Verify the target process exists in the kernel - if (!this.processes.has(targetPid)) return; - - const kernelView = new DataView(this.kernelMemory.buffer, this.scratchOffset); - // Write SYS_KILL into scratch: kill(targetPid, signum) - kernelView.setUint32(CH_SYSCALL, SYS_KILL, true); - kernelView.setBigInt64(CH_ARGS, BigInt(targetPid), true); // arg0 = pid - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(signum), true); // arg1 = sig - for (let i = 2; i < CH_ARGS_COUNT; i++) { - kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + private updateSharedMmapBackingFromProcessBuffer( + channel: ChannelInfo, + fd: number, + ptr: number, + len: number, + offset: number, + ): void { + if (len <= 0) return; + const backing = this.findSharedMmapBackingForFd(channel, fd); + if (!backing) return; + if ( + !Number.isSafeInteger(offset) + || offset < 0 + || !Number.isSafeInteger(offset + len) + ) { + backing.sizeValid = false; + this.invalidateSharedMmapBackingPages(backing); + return; + } + if (this.revalidateSharedMmapBacking(backing) !== 0) { + this.invalidateSharedMmapBackingPages(backing); + return; + } + const processMem = new Uint8Array(channel.memory.buffer); + if (ptr + len > processMem.length) { + this.reloadSharedMmapBackingRange(backing, offset, len); + return; } - - const handleChannel = this.kernelInstance.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - this.currentHandlePid = targetPid; - // Host-originated signal (SIGCHLD, SIGALRM, timer, etc.) is always a - // "shared" delivery — it lands on the process's pending queue, not a - // specific thread's. Force tid=0 so the kernel doesn't consult any - // per-thread state left over from a prior dispatch. - const setTid = this.kernelInstance.exports.kernel_set_current_tid as - ((tid: number) => void) | undefined; - if (setTid) setTid(0); try { - handleChannel(this.toKernelPtr(this.scratchOffset), targetPid); - } catch (err) { - // Non-fatal — signal delivery is best-effort from the host side - console.error(`[sendSignalToProcess] kernel threw for pid=${targetPid} sig=${signum}: ${err}`); - } finally { - this.currentHandlePid = 0; + this.copyRangeToSharedMmapBacking( + backing, + offset, + processMem.subarray(ptr, ptr + len), + false, + ); + backing.version++; + } catch { + this.invalidateSharedMmapBackingRange(backing, offset, len); } + } - // Check if the signal is deliverable (not blocked by the process) - const isBlocked = (this.kernelInstance!.exports.kernel_is_signal_blocked as - (pid: number, signum: number) => number)(targetPid, signum); - if (isBlocked) return; + private reloadSharedMmapBackingForFd( + channel: ChannelInfo, + fd: number, + exactSize?: number, + ): boolean { + const backing = this.findSharedMmapBackingForFd(channel, fd); + if (!backing) return true; + return this.reloadSharedMmapBacking(backing, exactSize); + } - // Signal is deliverable — wake any blocking syscall for this process + private reloadSharedMmapBackingForPath( + path: string, + exactSize?: number, + ): boolean { + const backing = this.findSharedMmapBackingForPath(path); + if (!backing) return true; + return this.reloadSharedMmapBacking(backing, exactSize); + } - // 1. Pending sleep (nanosleep, usleep, clock_nanosleep) - const pendingSleep = this.pendingSleeps.get(targetPid); - if (pendingSleep) { - clearTimeout(pendingSleep.timer); - this.pendingSleeps.delete(targetPid); - this.completeSleepWithSignalCheck( - pendingSleep.channel, pendingSleep.syscallNr, pendingSleep.origArgs, - pendingSleep.retVal, pendingSleep.errVal, - ); + private reloadSharedMmapBacking( + backing: SharedMmapBacking, + exactSize?: number, + ): boolean { + if (exactSize !== undefined && Number.isSafeInteger(exactSize) && exactSize >= 0) { + backing.size = exactSize; + backing.sizeValid = true; + } else if (this.revalidateSharedMmapBacking(backing) !== 0) { + this.invalidateSharedMmapBackingPages(backing); + return false; } - - // 2. Pending ppoll/poll retry — wake ALL threads for this pid. - // Snapshot-and-skip-if-replaced: retrySyscall runs handleSyscall - // synchronously, and a non-interruptible blocking wait (notably - // accept(), which has no EINTR path) re-inserts the SAME - // channelOffset key via pendingPollRetries.set when it re-parks on - // EAGAIN. JS Map iterators are not snapshots — a deleted-then- - // reinserted key reappears at the tail and the raw for..of would - // revisit it forever, livelocking the whole kernel worker thread. - // Mirror wakeBlockedPoll / wakeAllBlockedRetries. (Regression: - // SIGCHLD to a forking daemon's master parked in accept() — - // e.g. msmtpd delivering WordPress mail — wedged the kernel.) - const pollMatches = Array.from(this.pendingPollRetries.entries()).filter( - ([, e]) => e.channel.pid === targetPid, - ); - for (const [key, pollEntry] of pollMatches) { - if (this.pendingPollRetries.get(key) !== pollEntry) continue; - if (pollEntry.timer) clearTimeout(pollEntry.timer); - this.pendingPollRetries.delete(key); - if (this.processes.has(targetPid)) { - this.retrySyscall(pollEntry.channel); + if (backing.pages.size === 0) { + backing.version++; + return true; + } + const loadedPages = Array.from(backing.pages.keys()); + const replacements = new Map(); + try { + for (const page of loadedPages) { + replacements.set(page, this.readSharedMmapBackingPage(backing, page)); } + } catch { + this.invalidateSharedMmapBackingPages(backing, loadedPages); + return false; + } + for (const [page, bytes] of replacements) { + backing.pages.set(page, bytes); + backing.dirtyPages.delete(page); } + backing.version++; + return true; + } - // 3. Pending select/pselect6 retries (same snapshot rationale). - const selectMatches = Array.from(this.pendingSelectRetries.entries()).filter( - ([, e]) => e.channel.pid === targetPid, - ); - for (const [key, selectEntry] of selectMatches) { - if (this.pendingSelectRetries.get(key) !== selectEntry) continue; - clearTimeout(selectEntry.timer); - clearImmediate(selectEntry.timer); - this.pendingSelectRetries.delete(key); - if (!this.processes.has(targetPid)) continue; - if (selectEntry.syscallNr === SYS_SELECT) { - this.handleSelect(selectEntry.channel, selectEntry.origArgs); - } else { - this.handlePselect6(selectEntry.channel, selectEntry.origArgs); + private reloadSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): boolean { + if (len <= 0) return true; + const firstPage = Math.floor(offset / FILE_PAGE_SIZE); + const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); + const replacements = new Map(); + try { + for (let page = firstPage; page <= lastPage; page++) { + if (!backing.pages.has(page)) continue; + replacements.set(page, this.readSharedMmapBackingPage(backing, page)); } + } catch { + this.invalidateSharedMmapBackingPages( + backing, + Array.from({ length: lastPage - firstPage + 1 }, (_, index) => firstPage + index), + ); + return false; + } + for (const [page, bytes] of replacements) { + backing.pages.set(page, bytes); + backing.dirtyPages.delete(page); } + if (replacements.size > 0) backing.version++; + return true; } - // ----------------------------------------------------------------------- - // Process memory management - // - // The kernel's ensure_memory_covers() grows the KERNEL's Wasm memory - // (memory index 0 in the kernel module). But the - // process runs in a different WebAssembly.Memory. After brk/mmap/mremap - // syscalls, we must grow the process's memory to cover the returned - // addresses — otherwise the process gets "memory access out of bounds". - // ----------------------------------------------------------------------- + private invalidateSharedMmapBackingRange( + backing: SharedMmapBacking, + offset: number, + len: number, + ): void { + if (len <= 0) return; + const firstPage = Math.floor(offset / FILE_PAGE_SIZE); + const lastPage = Math.floor((offset + len - 1) / FILE_PAGE_SIZE); + this.invalidateSharedMmapBackingPages( + backing, + Array.from({ length: lastPage - firstPage + 1 }, (_, index) => firstPage + index), + ); + } - private ensureProcessMemoryCovers( - pid: number, - processMemory: WebAssembly.Memory, - syscallNr: number, - retVal: number, - origArgs: number[], + private invalidateSharedMmapBackingPages( + backing: SharedMmapBacking, + pages: Iterable = Array.from(backing.pages.keys()), ): void { - let endAddr = 0; - let mmapAddr = 0; - let mmapLen = 0; + for (const page of pages) { + // Direct-storage syscalls preflush dirty mappings. Preserve any dirty + // page if a focused caller bypassed that contract; clean stale pages are + // removed so completion-boundary refresh must reread them. + if (!backing.dirtyPages.has(page)) backing.pages.delete(page); + } + // Even when no page was loaded, mapped snapshots must be treated as stale. + backing.version++; + } - // MAP_FAILED is -1 (all bits set). For wasm64 processes retVal could be - // a large positive number when interpreted as unsigned, but the kernel - // returns -1 (sign-extended) which JS sees as -1. Use a simple < 0 - // check instead of comparing to a fixed 32-bit constant. - if (syscallNr === SYS_BRK) { - // retVal is the new program break address - if (retVal >= 0) endAddr = retVal; - } else if (syscallNr === SYS_MMAP) { - // retVal is the mapped address, origArgs[1] is the length - if (retVal >= 0) { - mmapAddr = retVal; - mmapLen = origArgs[1]; - endAddr = mmapAddr + mmapLen; + private findSharedMmapBackingForFd( + channel: ChannelInfo, + fd: number, + ): SharedMmapBacking | null { + if (this.sharedMmapBackings.size === 0 || fd < 0) return null; + const cacheKey = this.sharedMmapFdCacheKey(channel.pid, fd); + const cached = this.sharedMmapFdCache.get(cacheKey); + if (cached !== undefined) { + return cached.backingKey + ? this.sharedMmapBackings.get(cached.backingKey) ?? null + : null; + } + + const statResult = this.getFdStatForSharedMapping(channel, fd); + if (statResult.kind === "error") { + if (statResult.errno === EBADF) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); } - } else if (syscallNr === SYS_MREMAP) { - // retVal is the new address, origArgs[2] is the new length - if (retVal >= 0) { - mmapAddr = retVal; - mmapLen = origArgs[2]; - endAddr = mmapAddr + mmapLen; + return null; + } + if ((statResult.value.mode & 0o170000) !== 0o100000) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); + return null; + } + const hostHandle = statResult.value.hostHandle; + const keyResult = hostHandle === null + ? { kind: "error" as const, errno: ENOTSUP } + : this.resolveSharedMmapBackingKey(statResult.value, hostHandle); + if (keyResult.kind === "error") { + if (keyResult.errno === EBADF || keyResult.errno === ENOTSUP) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); } + return null; + } + const backing = this.sharedMmapBackings.get(keyResult.value); + if (backing) { + this.sharedMmapFdCache.set(cacheKey, { backingKey: backing.key }); + return backing; } + this.sharedMmapFdCache.set(cacheKey, { backingKey: null }); + return null; + } - const currentBytes = processMemory.buffer.byteLength; + private sharedMmapFdCacheKey(pid: number, fd: number): string { + return `${pid}:${fd}`; + } - if (endAddr > 0 && endAddr > currentBytes) { - const ptrWidth = this.processes.get(pid)?.ptrWidth ?? 4; - growMemoryToCover(processMemory, endAddr, ptrWidth); - // Memory.grow detaches any TypedArray bound to the previous SAB. - // Any cached framebuffer view on this pid is now invalid; the - // renderer must rebuild it on the next frame from the new - // Memory.buffer. Idempotent for pids without a binding. - this.kernel.framebuffers.rebindMemory(pid); + private invalidateSharedMmapFdCache(pid?: number, fd?: number): void { + if (pid === undefined || fd === undefined) { + this.sharedMmapFdCache.clear(); + return; } + this.sharedMmapFdCache.delete(this.sharedMmapFdCacheKey(pid, fd)); + } - // Zero the mmap'd region. Anonymous mmap must return zeroed pages (like - // Linux). The kernel allocates whole pages, so zero the full page-aligned - // region, not just the requested length. On Linux, bytes beyond the - // requested length up to the page boundary are also zeroed. Without this, - // reused addresses contain stale data from previous allocations, - // corrupting musl's malloc metadata (infinite loops / heap corruption). - // - // For mremap, the existing prefix [old_addr, old_addr + old_len) MUST be - // preserved (mremap is content-preserving — that's the contract mallocng's - // realloc relies on). Only zero the *new tail* [old_len, new_len) when the - // mapping grew in place (retVal === old_addr); the move case is handled - // by the memcpy below, which copies the prefix from the old buffer. - if (mmapLen > 0) { - const PAGE_SIZE = 65536; // Wasm page size - const alignedLen = Math.ceil(mmapLen / PAGE_SIZE) * PAGE_SIZE; - const newBytes = processMemory.buffer.byteLength; - let zeroStart = mmapAddr; - const zeroEnd = Math.min(mmapAddr + alignedLen, newBytes); - if (syscallNr === SYS_MREMAP) { - const oldAddr = origArgs[0] >>> 0; - const oldLen = origArgs[1] >>> 0; - if (mmapAddr === oldAddr && oldLen > 0) { - // In-place grow: prefix [oldAddr, oldAddr + oldLen) must remain - // untouched. Only the new tail [oldAddr + oldLen, ...) needs to be - // zeroed. Page-align the start so we don't tear partial-page bytes - // either way. - const oldEndPageAligned = Math.ceil((oldAddr + oldLen) / PAGE_SIZE) * PAGE_SIZE; - zeroStart = Math.max(zeroStart, oldEndPageAligned); - } - // Move case (mmapAddr !== oldAddr): the new region's prefix gets - // overwritten by the memcpy below; zeroing first is harmless and - // matches anonymous-mmap semantics for any tail bytes the memcpy - // doesn't touch. - } - if (zeroStart < zeroEnd) { - new Uint8Array(processMemory.buffer, zeroStart, zeroEnd - zeroStart).fill(0); + private invalidateSharedMmapFdCacheForPid(pid: number): void { + if (!this.sharedMmapFdCache) return; + const prefix = `${pid}:`; + for (const key of this.sharedMmapFdCache.keys()) { + if (key.startsWith(prefix)) this.sharedMmapFdCache.delete(key); + } + } + + private releaseFileSharedMapping(mapping: SharedMmapMapping): void { + if (mapping.backingKind !== "file" || !mapping.backingKey) return; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing) return; + this.releaseSharedMmapBackingReference(backing); + } + + private releasePreparedSharedMmap(context: PreparedFileSharedMmap): void { + this.releaseSharedMmapBackingReference(context.backing); + } + + private releaseSharedMmapBackingReference(backing: SharedMmapBacking): void { + backing.refCount = Math.max(0, backing.refCount - 1); + if (backing.refCount > 0) return; + if (!this.flushSharedMmapBackingRange(backing, 0, Number.MAX_SAFE_INTEGER)) { + // Keep the stable handle and dirty cache available for a later mapping + // of the same object. Closing here would irreversibly lose dirty bytes. + return; + } + this.kernel.releaseHostFileHandle(backing.handle); + this.sharedMmapBackings.delete(backing.key); + this.invalidateSharedMmapFdCache(); + } + + private mergeChangedByteRuns( + source: Uint8Array, + sourceOffset: number, + snapshot: Uint8Array, + snapshotOffset: number, + destination: Uint8Array, + destinationOffset: number, + len: number, + ): boolean { + let changed = false; + let i = 0; + while (i < len) { + while (i < len && source[sourceOffset + i] === snapshot[snapshotOffset + i]) i++; + if (i >= len) break; + const start = i; + do { i++; } while ( + i < len && source[sourceOffset + i] !== snapshot[snapshotOffset + i] + ); + destination.set(source.subarray(sourceOffset + start, sourceOffset + i), destinationOffset + start); + changed = true; + } + return changed; + } + + private rangeDiffersFromSnapshot( + source: Uint8Array, + sourceOffset: number, + snapshot: Uint8Array, + snapshotOffset: number, + len: number, + ): boolean { + const sourceByteOffset = source.byteOffset + sourceOffset; + const snapshotByteOffset = snapshot.byteOffset + snapshotOffset; + if (((sourceByteOffset | snapshotByteOffset | len) & 3) === 0) { + const sourceWords = new Uint32Array(source.buffer, sourceByteOffset, len / 4); + const snapshotWords = new Uint32Array(snapshot.buffer, snapshotByteOffset, len / 4); + for (let i = 0; i < sourceWords.length; i++) { + if (sourceWords[i] !== snapshotWords[i]) return true; } + return false; } + for (let i = 0; i < len; i++) { + if (source[sourceOffset + i] !== snapshot[snapshotOffset + i]) return true; + } + return false; + } - // For a *moving* mremap, restore the user's bytes from old_addr → new_addr. - // The kernel runs in its own Wasm linear memory, so it can't memcpy across - // the process's address space; mallocng's realloc and any other libc - // caller relies on mremap being content-preserving (Linux remaps physical - // pages — same effect, different mechanism). Without this copy, every - // mallocng allocation that crosses MMAP_THRESHOLD (131,052 bytes) on - // grow loses its prefix because mmap_anonymous returns a zeroed region. - // - // Runs after the zero-fill above, so the prefix is overwritten back to - // its original bytes; the tail (new_len > old_len) stays zeroed, matching - // anonymous-mmap semantics. The kernel's munmap of old_addr is metadata - // only — the underlying bytes are still in the process memory and safe - // to read here. - if ( - syscallNr === SYS_MREMAP && - retVal >= 0 && - retVal !== origArgs[0] && - origArgs[0] !== 0 && - origArgs[1] > 0 - ) { - const oldAddr = origArgs[0] >>> 0; - const oldLen = origArgs[1] >>> 0; - const newAddr = retVal >>> 0; - const newLen = origArgs[2] >>> 0; - const copyLen = Math.min(oldLen, newLen); - if (copyLen > 0) { - const buf = processMemory.buffer; - const totalBytes = buf.byteLength; - if (oldAddr + copyLen <= totalBytes && newAddr + copyLen <= totalBytes) { - const src = new Uint8Array(buf, oldAddr, copyLen); - new Uint8Array(buf, newAddr, copyLen).set(src); + private releaseAnonymousSharedMapping(mapping: SharedMmapMapping): void { + if (!mapping.backingKey) return; + const backing = this.anonymousSharedBackings?.get(mapping.backingKey); + if (!backing) return; + backing.refCount = Math.max(0, backing.refCount - 1); + if (backing.refCount === 0) this.anonymousSharedBackings.delete(backing.key); + } + + private releaseSharedMapping(mapping: SharedMmapMapping): void { + if (mapping.backingKind === "file") this.releaseFileSharedMapping(mapping); + else this.releaseAnonymousSharedMapping(mapping); + } + + /** + * Inherit host-side shared-memory metadata after the child process memory has + * been registered, but before its Worker starts executing. + */ + inheritProcessSharedMappings(parentPid: number, childPid: number): void { + const child = this.processes.get(childPid); + if (!child) throw new Error(`Process ${childPid} is not registered`); + + try { + const parentMap = this.sharedMappings.get(parentPid); + if (parentMap) { + const childMem = new Uint8Array(child.memory.buffer); + const childMap = new Map(); + // Install incrementally so the outer rollback can release references if + // a later mapping or SysV attachment fails. + this.sharedMappings.set(childPid, childMap); + for (const [mapAddr, mapping] of parentMap) { + if (!mapping.backingKey) continue; + const anonymousBacking = mapping.backingKind !== "file" + ? this.anonymousSharedBackings.get(mapping.backingKey) + : undefined; + const fileBacking = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : undefined; + if ((!anonymousBacking && !fileBacking) || mapAddr + mapping.len > childMem.length) { + throw new Error(`Cannot inherit shared mapping at 0x${mapAddr.toString(16)}`); + } + const latest = anonymousBacking + ? anonymousBacking.bytes.slice( + mapping.fileOffset, + mapping.fileOffset + mapping.len, + ) + : this.readSharedMmapBackingRange( + fileBacking!, + mapping.fileOffset, + mapping.len, + ); + childMem.set(latest, mapAddr); + const version = anonymousBacking?.version ?? fileBacking!.version; + if (anonymousBacking) anonymousBacking.refCount++; + else fileBacking!.refCount++; + childMap.set(mapAddr, { + ...mapping, + snapshot: latest, + seenVersion: version, + }); } + if (childMap.size === 0) this.sharedMappings.delete(childPid); } + + this.inheritSysvShmMappings(parentPid, childPid); + } catch (err) { + this.releaseAllSharedMemoryForProcess(childPid, false); + throw err; } } @@ -7275,13 +12453,13 @@ export class CentralizedKernelWorker { const chunkSize = Math.min(CH_DATA_SIZE, mapLen - written); // Set up pread syscall in kernel scratch: - // SYS_PREAD (64): (fd, buf_ptr, count, offset_lo, offset_hi) + // SYS_PREAD (64): (fd, buf_ptr, count, signed i64 offset) kernelView.setUint32(CH_SYSCALL, SYS_PREAD, true); kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); // fd kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); // buf_ptr (kernel memory) kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); // count - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset & 0xffffffff), true); // offset_lo - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(fileOffset / 0x100000000) | 0), true); // offset_hi + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(fileOffset), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); this.currentHandlePid = channel.pid; @@ -7290,8 +12468,10 @@ export class CentralizedKernelWorker { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); } catch { break; // pread failed, leave rest as zeros + } finally { + this.currentHandlePid = 0; } - this.currentHandlePid = 0; + if (this.finishSignalTermination(channel)) return; const bytesRead = Number(kernelView.getBigInt64(CH_RETURN, true)); if (bytesRead <= 0) break; // EOF or error @@ -7317,13 +12497,23 @@ export class CentralizedKernelWorker { private flushSharedMappings( channel: ChannelInfo, origArgs: number[], - ): void { + ): boolean { + // msync/munmap/MAP_FIXED are explicit publication points for anonymous + // mappings too, including the single-observer-before-fork case. + try { + this.syncAnonymousSharedMappingsFromProcess(channel, { force: true }); + this.syncFileSharedMappingsFromProcess(channel, { force: true }); + } catch { + return false; + } + const syncAddr = origArgs[0] >>> 0; const syncLen = origArgs[1] >>> 0; const pidMap = this.sharedMappings.get(channel.pid); - if (!pidMap || pidMap.size === 0) return; + if (!pidMap || pidMap.size === 0) return true; const syncEnd = syncAddr + syncLen; + let success = true; for (const [mapAddr, mapping] of pidMap) { const mapEnd = mapAddr + mapping.len; @@ -7339,86 +12529,608 @@ export class CentralizedKernelWorker { // File offset for the flush region const fileOffsetBase = mapping.fileOffset + (flushStart - mapAddr); - // Read from process memory and write to file via pwrite - this.pwriteFromProcessMemory( + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing || !this.flushSharedMmapBackingRange( + backing, + fileOffsetBase, + flushLen, + )) success = false; + continue; + } + if (!mapping.writable) continue; + if (mapping.backingKey) continue; + + // Compatibility for pre-page-cache tracking in focused exec harnesses. + if (!this.pwriteFromProcessMemory( channel, mapping.fd, flushStart, flushLen, fileOffsetBase, + )) success = false; + } + return success; + } + + /** + * Write data from process memory to a file via kernel pwrite syscalls. + */ + private pwriteFromProcessMemory( + channel: ChannelInfo, + fd: number, + processAddr: number, + len: number, + fileOffset: number, + ): boolean { + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const dataStart = this.scratchOffset + CH_DATA; + + if (processAddr + len > channel.memory.buffer.byteLength) return false; + const previousPid = this.currentHandlePid; + try { + let written = 0; + while (written < len) { + const chunkSize = Math.min(CH_DATA_SIZE, len - written); + // pwrite can grow an in-kernel Vec and therefore the kernel Wasm + // memory. Reacquire scratch views for every chunk; a view cached + // across kernel_handle_channel may have been detached by memory.grow. + const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const kernelMem = new Uint8Array(this.kernelMemory!.buffer); + + // Copy chunk from process memory to kernel scratch data area + const processMem = new Uint8Array(channel.memory.buffer); + kernelMem.set( + processMem.subarray(processAddr + written, processAddr + written + chunkSize), + dataStart, + ); + + // Set up pwrite syscall in kernel scratch: + // SYS_PWRITE (65): (fd, buf_ptr, count, signed i64 offset) + const curOffset = fileOffset + written; + kernelView.setUint32(CH_SYSCALL, SYS_PWRITE, true); + kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); + kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); + kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); + kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(curOffset), true); + kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, 0n, true); + kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + + this.currentHandlePid = channel.pid; + this.bindKernelTidForChannel(channel); + handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); + if (this.finishSignalTermination(channel)) return false; + + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + const bytesWritten = Number(resultView.getBigInt64(CH_RETURN, true)); + if (bytesWritten <= 0 || bytesWritten > chunkSize) return false; + + written += bytesWritten; + if (bytesWritten < chunkSize) return false; + } + return written === len; + } catch { + return false; + } finally { + this.currentHandlePid = previousPid; + } + } + + /** + * Remove shared mapping entries that overlap the munmap range. + */ + private cleanupSharedMappings(pid: number, addr: number, len: number): void { + const pidMap = this.sharedMappings.get(pid); + if (!pidMap) return; + + const unmapEnd = addr + len; + for (const [mapAddr, mapping] of Array.from(pidMap.entries())) { + const mapEnd = mapAddr + mapping.len; + const overlapStart = Math.max(addr, mapAddr); + const overlapEnd = Math.min(unmapEnd, mapEnd); + if (overlapStart >= overlapEnd) continue; + + if (overlapStart <= mapAddr && overlapEnd >= mapEnd) { + this.releaseSharedMapping(mapping); + pidMap.delete(mapAddr); + continue; + } + + if (overlapStart <= mapAddr) { + const trim = overlapEnd - mapAddr; + pidMap.delete(mapAddr); + mapping.fileOffset += trim; + mapping.len = mapEnd - overlapEnd; + if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(trim); + if (mapping.len > 0) pidMap.set(overlapEnd, mapping); + else this.releaseSharedMapping(mapping); + continue; + } + + if (overlapEnd >= mapEnd) { + mapping.len = overlapStart - mapAddr; + if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(0, mapping.len); + continue; + } + + const leftLen = overlapStart - mapAddr; + const rightSkip = overlapEnd - mapAddr; + const rightMapping: SharedMmapMapping = { + ...mapping, + fileOffset: mapping.fileOffset + rightSkip, + len: mapEnd - overlapEnd, + ...(mapping.snapshot ? { snapshot: mapping.snapshot.slice(rightSkip) } : {}), + }; + mapping.len = leftLen; + if (mapping.snapshot) mapping.snapshot = mapping.snapshot.slice(0, leftLen); + if (mapping.backingKey) { + const backing = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : this.anonymousSharedBackings.get(mapping.backingKey); + if (backing) backing.refCount++; + } + pidMap.set(overlapEnd, rightMapping); + } + + if (pidMap.size === 0) { + this.sharedMappings.delete(pid); + } + } + + private preflightFileSharedMremap(pid: number, origArgs: number[]): number { + const oldAddr = origArgs[0] >>> 0; + const newLen = origArgs[2] >>> 0; + const mapping = this.sharedMappings.get(pid)?.get(oldAddr); + if ( + !mapping + || mapping.backingKind !== "file" + || newLen <= mapping.len + ) return 0; + if (!mapping.backingKey) return EIO; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing) return EIO; + try { + this.ensureSharedMmapBackingRangeLoaded( + backing, + mapping.fileOffset + mapping.len, + newLen - mapping.len, ); + return 0; + } catch { + // The kernel has not moved or resized anything yet; the old mapping and + // tracker remain authoritative and can continue after the failed call. + return EIO; + } + } + + private remapSharedMapping( + pid: number, + oldAddr: number, + newAddr: number, + newLen: number, + ): void { + const pidMap = this.sharedMappings.get(pid); + const mapping = pidMap?.get(oldAddr); + if (!pidMap || !mapping) return; + + pidMap.delete(oldAddr); + if (mapping.backingKey && mapping.snapshot) { + const registration = this.processes.get(pid); + const fileBacking = mapping.backingKind === "file" + ? this.sharedMmapBackings.get(mapping.backingKey) + : undefined; + const anonymousBacking = mapping.backingKind !== "file" + ? this.anonymousSharedBackings.get(mapping.backingKey) + : undefined; + if (fileBacking && registration) { + this.ensureSharedMmapBackingRangeLoaded( + fileBacking, + mapping.fileOffset, + newLen, + ); + const latest = this.readSharedMmapBackingRange( + fileBacking, + mapping.fileOffset, + newLen, + ); + new Uint8Array(registration.memory.buffer).set(latest, newAddr); + mapping.snapshot = latest; + mapping.seenVersion = fileBacking.version; + } else if (anonymousBacking && registration) { + const required = mapping.fileOffset + newLen; + if (required > anonymousBacking.bytes.length) { + const grown = new Uint8Array(required); + grown.set(anonymousBacking.bytes); + const processMem = new Uint8Array(registration.memory.buffer); + if (newAddr + newLen <= processMem.length && newLen > mapping.len) { + grown.set( + processMem.subarray(newAddr + mapping.len, newAddr + newLen), + mapping.fileOffset + mapping.len, + ); + } + anonymousBacking.bytes = grown; + anonymousBacking.version++; + } + const latest = anonymousBacking.bytes.slice( + mapping.fileOffset, + mapping.fileOffset + newLen, + ); + new Uint8Array(registration.memory.buffer).set(latest, newAddr); + mapping.snapshot = latest; + mapping.seenVersion = anonymousBacking.version; + } else { + mapping.snapshot = mapping.snapshot.slice(0, newLen); + } + } + mapping.len = newLen; + pidMap.set(newAddr, mapping); + } + + /** + * Validate a PROT_WRITE upgrade before the kernel's no-op mprotect reports + * success. Read-only file mappings may be upgraded only when the original + * guest fd was a writable regular-file description. Mmap preflight retains + * that O_RDWR handle even for an initially read-only mapping, so this path + * never has to recover capability by reopening a pathname. + */ + private prepareFileSharedMappingsForWrite( + pid: number, + addr: number, + len: number, + ): number { + const pidMap = this.sharedMappings.get(pid); + if (!pidMap || len === 0) return 0; + const protectEnd = addr + len; + + for (const [mapAddr, mapping] of pidMap) { + if (mapping.backingKind !== "file") continue; + const mapEnd = mapAddr + mapping.len; + if (mapEnd <= addr || mapAddr >= protectEnd) continue; + if (mapping.writeAllowed !== true) return EACCES; + if (!mapping.backingKey) return EIO; + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (!backing) return EIO; + if (!backing.writable) return EIO; + } + return 0; + } + + /** Keep writeback eligibility aligned with successful mprotect ranges. */ + private updateSharedMappingProtection( + pid: number, + addr: number, + len: number, + writable: boolean, + ): void { + const pidMap = this.sharedMappings.get(pid); + // Writeback eligibility is monotonic: bytes dirtied while writable still + // need flushing after a later read-only downgrade. Track this at mapping + // granularity so mremap can continue moving one coherent interval. + if (!pidMap || len === 0 || !writable) return; + const protectEnd = addr + len; + + for (const [mapAddr, mapping] of pidMap) { + const mapEnd = mapAddr + mapping.len; + if (mapEnd <= addr || mapAddr >= protectEnd) continue; + mapping.writable = true; + } + } + + private withKernelCurrentPid(pid: number, operation: () => T): T { + const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as + ((pid: number) => void) | undefined; + const previousPid = this.currentHandlePid; + this.currentHandlePid = pid; + if (setCurrentPid) setCurrentPid(pid); + try { + return operation(); + } finally { + this.currentHandlePid = previousPid; + if (setCurrentPid) setCurrentPid(previousPid); + } + } + + private hasPeerSysvShmMapping(pid: number, mapAddr: number, segId: number): boolean { + for (const [otherPid, mappings] of this.shmMappings) { + for (const [otherAddr, mapping] of mappings) { + if (mapping.segId !== segId) continue; + if (otherPid === pid && otherAddr === mapAddr) continue; + return true; + } + } + return false; + } + + private syncSysvShmMappingsFromProcess( + process: Pick, + options: { force?: boolean } = {}, + ): boolean { + const pidMap = this.shmMappings?.get(process.pid); + if (!pidMap) return true; + const processMem = new Uint8Array(process.memory.buffer); + let success = true; + this.withKernelCurrentPid(process.pid, () => { + for (const [mapAddr, mapping] of pidMap) { + if (!options.force + && !this.hasPeerSysvShmMapping(process.pid, mapAddr, mapping.segId)) continue; + if (!this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping)) success = false; + } + }); + return success; + } + + /** Publish all current attachments before a new observer joins a segment. */ + private syncSysvShmSegmentFromMappedProcesses(segId: number): void { + for (const [pid, mappings] of this.shmMappings) { + const registration = this.processes.get(pid); + if (!registration) continue; + const processMem = new Uint8Array(registration.memory.buffer); + this.withKernelCurrentPid(pid, () => { + for (const [mapAddr, mapping] of mappings) { + if (mapping.segId === segId) { + this.mergeAndRefreshSysvShmMapping(processMem, mapAddr, mapping); + } + } + }); } } - /** - * Write data from process memory to a file via kernel pwrite syscalls. - */ - private pwriteFromProcessMemory( - channel: ChannelInfo, - fd: number, - processAddr: number, + private mappingDiffersFromSnapshot( + processMem: Uint8Array, + mapAddr: number, + snapshot: Uint8Array, len: number, - fileOffset: number, - ): void { - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as - (offset: KernelPointer, pid: number) => number; - const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - const kernelMem = new Uint8Array(this.kernelMemory!.buffer); - const dataStart = this.scratchOffset + CH_DATA; + ): boolean { + for (let offset = 0; offset < len; offset += 4096) { + const chunkLen = Math.min(4096, len - offset); + if (this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + snapshot, + offset, + chunkLen, + )) return true; + } + return false; + } - let written = 0; - while (written < len) { - const chunkSize = Math.min(CH_DATA_SIZE, len - written); + private mergeAndRefreshSysvShmMapping( + processMem: Uint8Array, + mapAddr: number, + mapping: SysvShmMapping, + ): boolean { + if (mapAddr + mapping.size > processMem.length) return false; + const currentVersion = this.shmSegmentVersions.get(mapping.segId) ?? 0; + const locallyChanged = !mapping.readOnly && this.mappingDiffersFromSnapshot( + processMem, + mapAddr, + mapping.snapshot, + mapping.size, + ); + if (!locallyChanged && mapping.seenVersion === currentVersion) return true; + + const authoritative = this.readSysvShmRange(mapping.segId, 0, mapping.size); + if (!authoritative) return false; + let published = false; + let success = true; + if (locallyChanged) { + for (let offset = 0; offset < mapping.size; offset += 4096) { + const chunkLen = Math.min(4096, mapping.size - offset); + if (!this.rangeDiffersFromSnapshot( + processMem, + mapAddr + offset, + mapping.snapshot, + offset, + chunkLen, + )) continue; + let i = 0; + while (i < chunkLen) { + while ( + i < chunkLen + && processMem[mapAddr + offset + i] === mapping.snapshot[offset + i] + ) i++; + if (i >= chunkLen) break; + const start = i; + do { i++; } while ( + i < chunkLen + && processMem[mapAddr + offset + i] !== mapping.snapshot[offset + i] + ); + const bytes = processMem.subarray( + mapAddr + offset + start, + mapAddr + offset + i, + ); + if (!this.writeSysvShmRange(mapping.segId, offset + start, bytes)) { + success = false; + break; + } + authoritative.set(bytes, offset + start); + published = true; + } + if (!success) break; + } + } - // Copy chunk from process memory to kernel scratch data area - const processMem = new Uint8Array(channel.memory.buffer); - kernelMem.set( - processMem.subarray(processAddr + written, processAddr + written + chunkSize), - dataStart, - ); + if (published) { + this.shmSegmentVersions.set(mapping.segId, currentVersion + 1); + } + processMem.set(authoritative, mapAddr); + mapping.snapshot = authoritative; + mapping.seenVersion = this.shmSegmentVersions.get(mapping.segId) ?? currentVersion; + return success; + } - // Set up pwrite syscall in kernel scratch: - // SYS_PWRITE (65): (fd, buf_ptr, count, offset_lo, offset_hi) - const curOffset = fileOffset + written; - kernelView.setUint32(CH_SYSCALL, SYS_PWRITE, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(fd), true); - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(dataStart), true); - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(chunkSize), true); - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(curOffset & 0xffffffff), true); - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(Math.floor(curOffset / 0x100000000) | 0), true); - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); + private readSysvShmRange(segId: number, offset: number, len: number): Uint8Array | null { + const readChunk = this.kernelInstance!.exports.kernel_ipc_shm_read_chunk as + ((shmid: number, offset: number, outPtr: KernelPointer, maxLen: number) => number) | undefined; + if (!readChunk) return null; + const result = new Uint8Array(len); + let transferred = 0; + while (transferred < len) { + const toRead = Math.min(CH_DATA_SIZE, len - transferred); + const chunkPtr = this.scratchOffset + CH_DATA; + const nRead = readChunk( + segId, + offset + transferred, + this.toKernelPtr(chunkPtr), + toRead, + ); + if (nRead < 0 || nRead > toRead) return null; + if (nRead === 0) break; + result.set( + new Uint8Array(this.kernelMemory!.buffer, chunkPtr, nRead), + transferred, + ); + transferred += nRead; + } + return result; + } - this.currentHandlePid = channel.pid; - this.bindKernelTidForChannel(channel); + private writeSysvShmRange(segId: number, offset: number, bytes: Uint8Array): boolean { + const writeChunk = this.kernelInstance!.exports.kernel_ipc_shm_write_chunk as + ((shmid: number, offset: number, dataPtr: KernelPointer, dataLen: number) => number) | undefined; + if (!writeChunk) return false; + let transferred = 0; + while (transferred < bytes.length) { + const toWrite = Math.min(CH_DATA_SIZE, bytes.length - transferred); + const chunkPtr = this.scratchOffset + CH_DATA; + new Uint8Array(this.kernelMemory!.buffer).set( + bytes.subarray(transferred, transferred + toWrite), + chunkPtr, + ); + const written = writeChunk( + segId, + offset + transferred, + this.toKernelPtr(chunkPtr), + toWrite, + ); + if (written <= 0 || written > toWrite) return false; + transferred += written; + } + return true; + } + + private inheritSysvShmMappings(parentPid: number, childPid: number): void { + const parentMap = this.shmMappings.get(parentPid); + if (!parentMap || parentMap.size === 0) return; + const child = this.processes.get(childPid); + if (!child) throw new Error(`Process ${childPid} is not registered`); + const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as + ((shmid: number, shmaddr: number, flags: number) => number) | undefined; + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + ((shmid: number) => number) | undefined; + if (!kernelShmat || !kernelShmdt) + throw new Error("Kernel lacks SysV SHM inheritance exports"); + + const childMem = new Uint8Array(child.memory.buffer); + const childMap = new Map(); + this.withKernelCurrentPid(childPid, () => { try { - handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch { - break; + for (const [mapAddr, mapping] of parentMap) { + if (mapAddr + mapping.size > childMem.length) { + throw new Error(`Cannot inherit SysV mapping at 0x${mapAddr.toString(16)}`); + } + const result = kernelShmat( + mapping.segId, + mapAddr, + mapping.readOnly ? SHM_RDONLY : 0, + ); + if (result < 0 || result !== mapping.size) { + throw new Error(`SysV shmat inheritance failed for segment ${mapping.segId}`); + } + const latest = this.readSysvShmRange(mapping.segId, 0, mapping.size); + if (!latest) { + kernelShmdt(mapping.segId); + throw new Error(`Cannot read inherited SysV segment ${mapping.segId}`); + } + childMem.set(latest, mapAddr); + childMap.set(mapAddr, { + ...mapping, + snapshot: latest, + seenVersion: this.shmSegmentVersions.get(mapping.segId) ?? mapping.seenVersion, + }); + } + } catch (err) { + for (const mapping of childMap.values()) kernelShmdt(mapping.segId); + childMap.clear(); + throw err; } - this.currentHandlePid = 0; - - const bytesWritten = Number(kernelView.getBigInt64(CH_RETURN, true)); - if (bytesWritten <= 0) break; - - written += bytesWritten; - if (bytesWritten < chunkSize) break; - } + }); + if (childMap.size > 0) this.shmMappings.set(childPid, childMap); } - /** - * Remove shared mapping entries that overlap the munmap range. - */ - private cleanupSharedMappings(pid: number, addr: number, len: number): void { - const pidMap = this.sharedMappings.get(pid); + private releaseAllSysvShmMappingsForProcess( + pid: number, + publish: boolean = true, + ): void { + const pidMap = this.shmMappings?.get(pid); if (!pidMap) return; + const registration = this.processes.get(pid); + if (publish && registration) { + this.syncSysvShmMappingsFromProcess(registration, { force: true }); + } + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + ((shmid: number) => number) | undefined; + if (kernelShmdt) { + this.withKernelCurrentPid(pid, () => { + for (const mapping of pidMap.values()) kernelShmdt(mapping.segId); + }); + } + this.shmMappings.delete(pid); + } - const unmapEnd = addr + len; - for (const [mapAddr, mapping] of pidMap) { - const mapEnd = mapAddr + mapping.len; - // Remove if fully contained in unmap range - if (mapAddr >= addr && mapEnd <= unmapEnd) { - pidMap.delete(mapAddr); + private releaseAllSharedMemoryForProcess(pid: number, publish: boolean = true): void { + const releasing = this.sharedMemoryReleasePids ??= new Set(); + if (releasing.has(pid)) return; + releasing.add(pid); + try { + const registration = this.processes?.get(pid); + const channel = registration?.channels?.[0]; + if (publish && registration) { + // Teardown must continue even if one backing is no longer readable or + // writable. File backings retain dirty pages on failed final writeback; + // SysV/anonymous cleanup must not be skipped because of that failure. + try { + this.syncAnonymousSharedMappingsFromProcess(registration, { force: true }); + } catch {} + try { + this.syncFileSharedMappingsFromProcess(registration, { force: true }); + } catch {} + try { + this.syncSysvShmMappingsFromProcess(registration, { force: true }); + } catch {} + if (channel) { + const mappings = this.sharedMappings.get(pid); + if (mappings) { + for (const [addr, mapping] of mappings) { + if (!mapping.writable) continue; + if (mapping.backingKind === "file" && mapping.backingKey) { + const backing = this.sharedMmapBackings.get(mapping.backingKey); + if (backing) this.flushSharedMmapBackingRange( + backing, + mapping.fileOffset, + mapping.len, + ); + continue; + } + if (mapping.backingKey) continue; + this.pwriteFromProcessMemory( + channel, + mapping.fd, + addr, + mapping.len, + mapping.fileOffset, + ); + } + } + } } - } - if (pidMap.size === 0) { - this.sharedMappings.delete(pid); + const mappings = this.sharedMappings?.get(pid); + if (mappings) { + for (const mapping of mappings.values()) this.releaseSharedMapping(mapping); + this.sharedMappings?.delete(pid); + } + this.invalidateSharedMmapFdCacheForPid(pid); + if (this.shmMappings) this.releaseAllSysvShmMappingsForProcess(pid, false); + } finally { + releasing.delete(pid); } } @@ -7468,7 +13180,8 @@ export class CentralizedKernelWorker { } reserveHostRegion(pid: number, len: number): number { - const reserveHostRegionFn = this.kernelInstance!.exports.kernel_reserve_host_region as + const reserveHostRegionFn = this.kernelInstance!.exports + .kernel_reserve_host_region as ((pid: number, len: KernelPointer) => KernelPointer) | undefined; if (!reserveHostRegionFn) { throw new Error( @@ -7642,6 +13355,67 @@ export class CentralizedKernelWorker { * Start a TCP server for a listening socket, bridging real TCP connections * into the kernel's pipe-buffer-backed accept path. */ + private reconcileReusedTcpListenerKey( + pid: number, + fd: number, + newPort: number, + oldTarget: TcpListenerTarget | undefined, + existing: TcpListenerBridge, + ): TcpListenerBridge | undefined { + const oldPort = existing.port; + const oldTargets = this.tcpListenerTargets.get(oldPort) ?? []; + const retained = oldTargets.filter(target => + !(target.pid === pid && target.fd === fd)); + const oldAlias = oldTarget?.acceptWakeIdx !== undefined + ? this.resolveInheritedListenerFd(pid, fd, oldTarget.acceptWakeIdx) + : null; + if (oldAlias && oldAlias.fd !== fd + && !retained.some(target => + target.pid === pid && target.fd === oldAlias.fd)) { + retained.push({ pid, ...oldAlias }); + } + + if (retained.length === 0) { + this.tcpListenerTargets.delete(oldPort); + if (oldPort !== newPort) { + this.tcpListenerRRIndex.delete(oldPort); + const virtualKey = this.tcpVirtualListenerKeys.get(oldPort); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(oldPort); + } + } + } else { + this.tcpListenerTargets.set(oldPort, retained); + const oldIndex = this.tcpListenerRRIndex.get(oldPort) ?? 0; + this.tcpListenerRRIndex.set(oldPort, oldIndex % retained.length); + } + + const oldKey = `${pid}:${fd}`; + this.tcpListeners.delete(oldKey); + if (oldAlias && oldAlias.fd !== fd) { + const aliasKey = `${pid}:${oldAlias.fd}`; + if (!this.tcpListeners.has(aliasKey)) { + this.tcpListeners.set(aliasKey, existing); + } + } else if (retained.length > 0) { + const replacement = retained[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { + ...existing, + pid: replacement.pid, + }); + } + } else if (oldPort !== newPort) { + existing.server.close(); + } + + // A listener on the same port can keep using the already-bound Node + // server while the target identity moves to the newly reused fd. + return oldPort === newPort ? existing : undefined; + } + private startTcpListener( pid: number, fd: number, @@ -7649,8 +13423,38 @@ export class CentralizedKernelWorker { addr: [number, number, number, number] = [0, 0, 0, 0], ): void { const key = `${pid}:${fd}`; - // Avoid duplicate listeners on the same pid:fd - if (this.tcpListeners.has(key)) return; + const getAcceptWake = this.kernelInstance!.exports + .kernel_get_fd_accept_wake_idx as + ((pid: number, fd: number) => number) | undefined; + const liveWakeIdx = getAcceptWake?.(pid, fd) ?? -1; + let reusableListener: TcpListenerBridge | undefined; + const existing = this.tcpListeners.get(key); + if (existing) { + const existingTarget = this.tcpListenerTargets + .get(existing.port) + ?.find((target) => target.pid === pid && target.fd === fd); + const existingWakeIdx = existingTarget?.acceptWakeIdx; + if ( + (existingWakeIdx === undefined && existing.port === port) || + (existingWakeIdx !== undefined && existingWakeIdx === liveWakeIdx) + ) { + if ( + existingTarget && + existingWakeIdx === undefined && + liveWakeIdx >= 0 + ) { + existingTarget.acceptWakeIdx = liveWakeIdx; + } + return; + } + reusableListener = this.reconcileReusedTcpListenerKey( + pid, + fd, + port, + existingTarget, + existing, + ); + } // Register this pid:fd as a target for this port (needed for both // Node.js TCP bridging and browser service worker bridging via @@ -7661,25 +13465,47 @@ export class CentralizedKernelWorker { } const targets = this.tcpListenerTargets.get(port)!; if (!targets.some(t => t.pid === pid && t.fd === fd)) { - targets.push({ pid, fd }); + targets.push({ + pid, + fd, + ...(liveWakeIdx >= 0 ? { acceptWakeIdx: liveWakeIdx } : {}), + }); } - if (this.io.network?.listenTcp) { + if (this.io.network?.listenTcp && !this.tcpVirtualListenerKeys.has(port)) { const result = this.io.network.listenTcp( key, new Uint8Array(addr), port, { - accept: (peer, _local, remote) => - this.handleIncomingVirtualTcpConnection(pid, fd, peer, remote), + accept: (peer, _local, remote) => { + const target = this.pickListenerTarget(port); + if (!target) return 113; // EHOSTUNREACH + return this.handleIncomingVirtualTcpConnection( + target.pid, + target.fd, + peer, + remote, + ); + }, }, ); if (result !== 0) { console.warn(`virtual TCP listener registration failed on port ${port}: errno ${result}`); + } else { + this.tcpVirtualListenerKeys.set(port, key); } } if (!this.netModule) return; // Not in Node.js environment — no real TCP server + if (reusableListener) { + this.tcpListeners.set(key, { + ...reusableListener, + pid, + port, + }); + return; + } // If another process already has a TCP server on this port, share it for (const [, listener] of this.tcpListeners) { @@ -7692,7 +13518,7 @@ export class CentralizedKernelWorker { const net = this.netModule; const connections = new Set(); - const server = net.createServer((clientSocket) => { + const server = net.createServer({ allowHalfOpen: true }, (clientSocket) => { // Pick target via round-robin among registered processes for this port const target = this.pickListenerTarget(port); if (target) { @@ -7728,10 +13554,9 @@ export class CentralizedKernelWorker { const alive = targets.filter(t => this.processes.has(t.pid)); if (alive.length === 0) return null; - // Update targets list to remove dead processes - if (alive.length !== targets.length) { - this.tcpListenerTargets.set(port, alive); - } + // Do not prune unregistered targets here: a fork/spawn child owns its + // kernel listener before async Worker registration completes. Explicit + // process teardown removes truly dead targets. // If there are fork children among targets, prefer them over the original // listener (the master doesn't accept connections, workers do). @@ -7869,7 +13694,7 @@ export class CentralizedKernelWorker { if (entry.channel.pid !== pid) continue; if (entry.timer !== null) clearTimeout(entry.timer); this.pendingPollRetries.delete(key); - if (this.processes.has(pid)) this.retrySyscall(entry.channel); + if (this.isRegisteredChannel(entry.channel)) this.retrySyscall(entry.channel); break; } } @@ -8025,10 +13850,14 @@ export class CentralizedKernelWorker { (pid: number, pipeIdx: number) => number; const pipeIsReadOpen = this.kernelInstance!.exports.kernel_pipe_is_read_open as (pid: number, pipeIdx: number) => number; - + const pipeHasReaders = this.kernelInstance!.exports.kernel_pipe_has_readers as + (pid: number, pipeIdx: number) => number; // Queue for incoming TCP data (written to recv pipe) const inboundQueue: Buffer[] = []; let clientEnded = false; + let clientClosed = false; + let guestWriteEnded = false; + let recvPipeWriteClosed = false; let pumpPending = false; let cleaned = false; @@ -8037,15 +13866,30 @@ export class CentralizedKernelWorker { const pipeIsWriteOpen = this.kernelInstance!.exports.kernel_pipe_is_write_open as (pid: number, pipeIdx: number) => number; + const closeRecvPipeWrite = () => { + if (recvPipeWriteClosed) return; + recvPipeWriteClosed = true; + pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + // EOF is readable state even when the peer sent no data. + this.notifyPipeReadable(recvPipeIdx); + }; + // Drain inbound queue into recv pipe const drainInbound = () => { + if (pipeIsReadOpen(GLOBAL_PIPE_PID, recvPipeIdx) === 0) { + inboundQueue.length = 0; + if (clientEnded) closeRecvPipeWrite(); + return; + } const mem = this.getKernelMem(); + let wroteAny = false; while (inboundQueue.length > 0) { const chunk = inboundQueue[0]!; const toWrite = Math.min(chunk.length, 65536); mem.set(chunk.subarray(0, toWrite), scratchOffset); const written = pipeWrite(GLOBAL_PIPE_PID, recvPipeIdx, this.toKernelPtr(scratchOffset), toWrite); if (written <= 0) break; // Pipe full, retry next pump + wroteAny = true; if (written >= chunk.length) { inboundQueue.shift(); } else { @@ -8053,7 +13897,10 @@ export class CentralizedKernelWorker { } } if (clientEnded && inboundQueue.length === 0) { - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + closeRecvPipeWrite(); + } + if (wroteAny) { + this.notifyPipeReadable(recvPipeIdx); } }; @@ -8073,6 +13920,9 @@ export class CentralizedKernelWorker { clientSocket.write(outData); } } + if (totalRead > 0) { + this.notifyPipeWritable(sendPipeIdx); + } return totalRead; }; @@ -8088,20 +13938,30 @@ export class CentralizedKernelWorker { const pump = () => { pumpPending = false; - if (cleaned || !this.processes.has(pid)) { - cleanup(); - return; - } + if (cleaned) return; drainInbound(); const readN = drainOutbound(); - // Check if PHP closed its write end of the send pipe const writeOpen = pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx); - if (writeOpen === 0 && readN === 0) { - if (!clientSocket.destroyed) { + const hasReaders = pipeHasReaders(GLOBAL_PIPE_PID, recvPipeIdx); + if (writeOpen === 0 && readN === 0 && !guestWriteEnded) { + guestWriteEnded = true; + if (!clientSocket.destroyed && !clientSocket.writableEnded) { + // SHUT_WR is a half-close: send FIN after queued bytes but keep the + // real receive half alive until the guest closes it or the peer ends. clientSocket.end(); } + } + if (writeOpen === 0 && hasReaders <= 0) { + cleanup(); + return; + } + if (guestWriteEnded && clientEnded && inboundQueue.length === 0) { + cleanup(); + return; + } + if (clientClosed && inboundQueue.length === 0) { cleanup(); return; } @@ -8118,14 +13978,9 @@ export class CentralizedKernelWorker { // Incoming TCP data → write directly to recv pipe, queue overflow clientSocket.on("data", (chunk: Buffer) => { + if (cleaned) return; inboundQueue.push(chunk); - if (!this.processes.has(pid)) { cleanup(); return; } drainInbound(); - // Wake readers + pollers watching this recv pipe + broad wake. - // The pid filter limits the targeted poll wake to this listener - // pid (the recvPipeIdx is per-connection so any matching poller - // is necessarily owned by this pid; the filter is defensive). - this.notifyPipeReadable(recvPipeIdx, pid); // Schedule pump to handle outbound + close detection schedulePump(); }); @@ -8138,10 +13993,18 @@ export class CentralizedKernelWorker { clientSocket.on("error", () => { clientEnded = true; clientSocket.destroy(); + cleanup(); }); clientSocket.on("close", () => { connections.delete(clientSocket); + clientClosed = true; + clientEnded = true; + // A clean close can arrive while pre-FIN bytes are still queued because + // the guest receive pipe is full. Let the pump deliver those bytes + // before releasing the pipe ends. The error path above remains an + // immediate reset/abort. + schedulePump(); }); // Register this connection for piggyback flushing @@ -8156,12 +14019,14 @@ export class CentralizedKernelWorker { const cleanup = () => { if (cleaned) return; cleaned = true; + inboundQueue.length = 0; // Close the host's ends of both pipes: // recvPipe: host is the writer → close write end // sendPipe: host is the reader → close read end - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + closeRecvPipeWrite(); pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); - connections.delete(clientSocket); + // A closed host read end makes any parked guest writer fail with EPIPE. + this.notifyPipeWritable(sendPipeIdx); // Remove from tcpConnections tracking const arr = this.tcpConnections?.get(pid); if (arr) { @@ -8170,7 +14035,9 @@ export class CentralizedKernelWorker { if (arr.length === 0) this.tcpConnections?.delete(pid); } if (!clientSocket.destroyed) { - clientSocket.destroy(); + // Flush queued bytes, send FIN, then release the Node handle. The + // operating system owns subsequent TCP close-state timing. + clientSocket.destroySoon(); } }; } @@ -8213,43 +14080,73 @@ export class CentralizedKernelWorker { (pid: number, pipeIdx: number) => number; const pipeIsWriteOpen = this.kernelInstance.exports.kernel_pipe_is_write_open as (pid: number, pipeIdx: number) => number; + const pipeIsReadOpen = this.kernelInstance.exports.kernel_pipe_is_read_open as + (pid: number, pipeIdx: number) => number; + const pipeHasReaders = this.kernelInstance.exports.kernel_pipe_has_readers as + (pid: number, pipeIdx: number) => number; let cleaned = false; + let recvPipeWriteClosed = false; + let guestReadShutdown = false; + let guestWriteEnded = false; + let pendingInbound: Uint8Array | null = null; let pumpPending = false; const scratchOffset = this.tcpScratchOffset; + const closeRecvPipeWrite = () => { + if (recvPipeWriteClosed) return; + recvPipeWriteClosed = true; + pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + }; + const cleanup = () => { if (cleaned) return; cleaned = true; - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); + closeRecvPipeWrite(); pipeCloseRead(GLOBAL_PIPE_PID, sendPipeIdx); peer.close(); - this.notifyPipeReadable(recvPipeIdx, pid); + this.notifyPipeReadable(recvPipeIdx); this.notifyPipeWritable(sendPipeIdx); this.scheduleWakeBlockedRetries(); }; const drainInbound = () => { + if (pipeIsReadOpen(GLOBAL_PIPE_PID, recvPipeIdx) === 0) { + pendingInbound = null; + if (!guestReadShutdown) { + guestReadShutdown = true; + peer.shutdown(0); + } + return; + } for (;;) { let data: Uint8Array; - try { - data = peer.recv(65536, 0); - } catch (e: any) { - if (e?.errno === 11) return; - cleanup(); - return; + if (pendingInbound) { + data = pendingInbound; + } else { + try { + data = peer.recv(65536, 0); + } catch (e: any) { + if (e?.errno === 11) return; + cleanup(); + return; + } } if (data.length === 0) { - pipeCloseWrite(GLOBAL_PIPE_PID, recvPipeIdx); - this.notifyPipeReadable(recvPipeIdx, pid); + pendingInbound = null; + closeRecvPipeWrite(); + this.notifyPipeReadable(recvPipeIdx); return; } const written = this.writePipeChunked(pipeWrite, GLOBAL_PIPE_PID, recvPipeIdx, data); if (written < data.length) { - // The pipe is full. A later pump tick will retry once the guest reads. + // `peer.recv` consumes bytes, so retain the unwritten suffix while + // the guest receive pipe is full and retry it on a later pump tick. + pendingInbound = data.subarray(written); return; } - this.notifyPipeReadable(recvPipeIdx, pid); + pendingInbound = null; + this.notifyPipeReadable(recvPipeIdx); } }; @@ -8273,16 +14170,19 @@ export class CentralizedKernelWorker { if (cleaned) { return; } - if (!this.processes.has(pid)) { - drainOutbound(); + drainInbound(); + drainOutbound(); + const writeOpen = pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx); + const hasReaders = pipeHasReaders(GLOBAL_PIPE_PID, recvPipeIdx); + if (writeOpen === 0 && !guestWriteEnded) { + guestWriteEnded = true; peer.shutdown(1); + } + if (writeOpen === 0 && hasReaders <= 0) { cleanup(); return; } - drainInbound(); - drainOutbound(); - if (pipeIsWriteOpen(GLOBAL_PIPE_PID, sendPipeIdx) === 0) { - peer.shutdown(1); + if (guestWriteEnded && recvPipeWriteClosed) { cleanup(); return; } @@ -8358,28 +14258,41 @@ export class CentralizedKernelWorker { if (filtered.length === 0) { this.tcpListenerTargets.delete(port); this.tcpListenerRRIndex.delete(port); + const virtualKey = this.tcpVirtualListenerKeys.get(port); + if (virtualKey) { + this.io.network?.closeTcpListener?.(virtualKey); + this.tcpVirtualListenerKeys.delete(port); + } } else { this.tcpListenerTargets.set(port, filtered); } } - for (const [key, entry] of this.tcpListeners) { - if (entry.pid === pid) { - this.io.network?.closeTcpListener?.(key); - // Only close the server if no other processes share this port - const hasOtherTargets = this.tcpListenerTargets.has(entry.port); - if (!hasOtherTargets) { - entry.server.close(); - for (const conn of entry.connections) { - conn.destroy(); - } - entry.connections.clear(); + const keyPrefix = `${pid}:`; + for (const [key, entry] of Array.from(this.tcpListeners)) { + if (!key.startsWith(keyPrefix)) continue; + this.tcpListeners.delete(key); + // Accepted sockets have independent pipe ownership and may still belong + // to a fork child. Their pumps close them when the final pipe references + // disappear; listener teardown only stops new accepts. + const remainingTargets = this.tcpListenerTargets.get(entry.port); + if (!remainingTargets || remainingTargets.length === 0) { + entry.server.close(); + } else { + // Fork inheritance adds listener targets without re-running listen(2). + // Keep the shared server reachable under a surviving owner's key so + // final-owner cleanup can close it instead of leaking the port. + const replacement = remainingTargets[0]!; + const replacementKey = `${replacement.pid}:${replacement.fd}`; + if (!this.tcpListeners.has(replacementKey)) { + this.tcpListeners.set(replacementKey, { + ...entry, + pid: replacement.pid, + }); } - this.tcpListeners.delete(key); } } this.tcpConnections.delete(pid); - this.shmMappings.delete(pid); } // ========================================================================= @@ -8501,89 +14414,149 @@ export class CentralizedKernelWorker { this.relistenChannel(channel); } - /** shmat: allocate address via kernel mmap, copy segment data to process memory */ - private handleIpcShmat(channel: ChannelInfo, args: number[]): void { - const [shmid, _shmaddr, _flags] = args; - - // Set current pid for kernel_ipc_* exports - const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as ((pid: number) => void) | undefined; - if (setCurrentPid) setCurrentPid(channel.pid); - - const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as (shmid: number, shmaddr: number, flags: number) => number; - const sizeOrErr = kernelShmat(shmid, _shmaddr, _flags); - if (sizeOrErr < 0) { - this.completeChannelRaw(channel, sizeOrErr, -sizeOrErr); - this.relistenChannel(channel); - return; - } - const size = sizeOrErr; - - // Synthesize mmap to allocate virtual address space for this pid + private runSyntheticMemorySyscall( + channel: ChannelInfo, + syscallNr: number, + args: number[], + ): { retVal: number; errVal: number } { const kernelView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); - kernelView.setUint32(CH_SYSCALL, SYS_MMAP, true); - kernelView.setBigInt64(CH_ARGS + 0 * CH_ARG_SIZE, BigInt(0), true); // addr hint = NULL - kernelView.setBigInt64(CH_ARGS + 1 * CH_ARG_SIZE, BigInt(size), true); // length - kernelView.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(3), true); // prot = PROT_READ|PROT_WRITE - kernelView.setBigInt64(CH_ARGS + 3 * CH_ARG_SIZE, BigInt(0x22), true); // flags = MAP_PRIVATE|MAP_ANONYMOUS - kernelView.setBigInt64(CH_ARGS + 4 * CH_ARG_SIZE, BigInt(-1), true); // fd = -1 - kernelView.setBigInt64(CH_ARGS + 5 * CH_ARG_SIZE, BigInt(0), true); // offset = 0 - - const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as (offset: KernelPointer, pid: number) => number; + kernelView.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < CH_ARGS_COUNT; i++) { + kernelView.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i] ?? 0), true); + } + const handleChannel = this.kernelInstance!.exports.kernel_handle_channel as + (offset: KernelPointer, pid: number) => number; + const previousPid = this.currentHandlePid; this.currentHandlePid = channel.pid; this.bindKernelTidForChannel(channel); try { handleChannel(this.toKernelPtr(this.scratchOffset), channel.pid); - } catch (err) { - console.error(`[handleIpcShmat] mmap failed for pid=${channel.pid}:`, err); - this.completeChannelRaw(channel, -12, 12); // ENOMEM - this.relistenChannel(channel); - return; } finally { - this.currentHandlePid = 0; + this.currentHandlePid = previousPid; + } + if (this.finishSignalTermination(channel)) { + return { retVal: -EINTR_ERRNO, errVal: EINTR_ERRNO }; } + const resultView = new DataView(this.kernelMemory!.buffer, this.scratchOffset); + return { + retVal: Number(resultView.getBigInt64(CH_RETURN, true)), + errVal: resultView.getUint32(CH_ERRNO, true), + }; + } - const addr = Number(kernelView.getBigInt64(CH_RETURN, true)); - if (addr < 0) { - this.completeChannelRaw(channel, -12, 12); // ENOMEM + /** shmat: allocate a process interval and attach it to authoritative bytes. */ + private handleIpcShmat(channel: ChannelInfo, args: number[]): void { + const [shmid, shmaddr, flags] = args; + + // A previously sole observer may not have published at ordinary boundaries. + // Force it current before this new attachment reads the segment. + this.syncSysvShmSegmentFromMappedProcesses(shmid); + + const kernelShmat = this.kernelInstance!.exports.kernel_ipc_shmat as + (shmid: number, shmaddr: number, flags: number) => number; + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + (shmid: number) => number; + const sizeOrErr = this.withKernelCurrentPid( + channel.pid, + () => kernelShmat(shmid, shmaddr, flags), + ); + if (sizeOrErr < 0) { + this.completeChannelRaw(channel, sizeOrErr, -sizeOrErr); this.relistenChannel(channel); return; } + const size = sizeOrErr; - // Grow process memory to cover the allocated address - this.ensureProcessMemoryCovers(channel.pid, channel.memory, SYS_MMAP, addr, [0, size, 3, 0x22, -1, 0]); + const readOnly = (flags & SHM_RDONLY) !== 0; + const prot = readOnly ? PROT_READ : PROT_READ | PROT_WRITE; + let allocatedAddr: number | null = null; + const rollback = () => { + if (allocatedAddr !== null) { + try { this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [allocatedAddr, size]); } catch {} + if (this.hostReaped?.has(channel.pid)) return; + } + try { + this.withKernelCurrentPid(channel.pid, () => kernelShmdt(shmid)); + } catch {} + }; - // Transfer segment data from kernel to process memory via read_chunk - const readChunk = this.kernelInstance!.exports.kernel_ipc_shm_read_chunk as - (shmid: number, offset: number, outPtr: KernelPointer, maxLen: number) => number; - const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const chunkSize = CH_DATA_SIZE; - const chunkPtr = this.scratchOffset + CH_DATA; - let transferred = 0; - while (transferred < size) { - const remaining = size - transferred; - const toRead = Math.min(remaining, chunkSize); - const nRead = readChunk(shmid, transferred, this.toKernelPtr(chunkPtr), toRead); - if (nRead <= 0) break; - processMem.set(kernelMem.subarray(chunkPtr, chunkPtr + nRead), (addr >>> 0) + transferred); - transferred += nRead; - } + try { + const mmap = this.runSyntheticMemorySyscall(channel, SYS_MMAP, [ + shmaddr >>> 0, + size, + prot, + 0x22, // MAP_PRIVATE | MAP_ANONYMOUS: host supplies sharing. + -1, + 0, + ]); + if (this.hostReaped?.has(channel.pid)) return; + if (mmap.retVal < 0) { + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + const errno = mmap.errVal || ENOMEM; + this.completeChannelRaw(channel, -errno, errno); + this.relistenChannel(channel); + return; + } + allocatedAddr = mmap.retVal >>> 0; + // Unlike mmap, a non-null shmat address is not merely a fallback hint. + if (shmaddr !== 0 && allocatedAddr !== (shmaddr >>> 0)) { + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + this.completeChannelRaw(channel, -EINVAL, EINVAL); + this.relistenChannel(channel); + return; + } - // Track the mapping for shmdt - let pidMappings = this.shmMappings.get(channel.pid); - if (!pidMappings) { - pidMappings = new Map(); - this.shmMappings.set(channel.pid, pidMappings); + this.ensureProcessMemoryCovers( + channel.pid, + channel.memory, + SYS_MMAP, + allocatedAddr, + [shmaddr, size, prot, 0x22, -1, 0], + ); + const snapshot = this.withKernelCurrentPid( + channel.pid, + () => this.readSysvShmRange(shmid, 0, size), + ); + const processMem = new Uint8Array(channel.memory.buffer); + if (!snapshot || allocatedAddr + size > processMem.length) { + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + this.completeChannelRaw(channel, -EIO, EIO); + this.relistenChannel(channel); + return; + } + processMem.set(snapshot, allocatedAddr); + + let pidMappings = this.shmMappings.get(channel.pid); + if (!pidMappings) { + pidMappings = new Map(); + this.shmMappings.set(channel.pid, pidMappings); + } + pidMappings.set(allocatedAddr, { + segId: shmid, + size, + readOnly, + snapshot, + seenVersion: this.shmSegmentVersions.get(shmid) ?? 0, + }); + } catch (err) { + console.error(`[handleIpcShmat] mmap failed for pid=${channel.pid}:`, err); + rollback(); + if (this.hostReaped?.has(channel.pid)) return; + this.completeChannelRaw(channel, -ENOMEM, ENOMEM); + this.relistenChannel(channel); + return; } - pidMappings.set(addr >>> 0, { segId: shmid, size }); - this.completeChannelRaw(channel, addr, 0); + this.completeChannelRaw(channel, allocatedAddr!, 0); this.relistenChannel(channel); } - /** shmdt: copy process memory back to segment, untrack mapping */ + /** shmdt: publish this attachment, detach exactly once, and unmap it. */ private handleIpcShmdt(channel: ChannelInfo, args: number[]): void { - const addr = args[0]; + const addr = args[0] >>> 0; const pidMappings = this.shmMappings.get(channel.pid); if (!pidMappings) { this.completeChannelRaw(channel, -22, 22); // EINVAL @@ -8597,37 +14570,38 @@ export class CentralizedKernelWorker { return; } - // Set current pid for kernel exports - const setCurrentPid = this.kernelInstance!.exports.kernel_set_current_pid as ((pid: number) => void) | undefined; - if (setCurrentPid) setCurrentPid(channel.pid); - - // Sync process memory back to kernel segment via write_chunk - const writeChunk = this.kernelInstance!.exports.kernel_ipc_shm_write_chunk as - (shmid: number, offset: number, dataPtr: KernelPointer, dataLen: number) => number; const processMem = new Uint8Array(channel.memory.buffer); - const kernelMem = this.getKernelMem(); - const chunkSize = CH_DATA_SIZE; - const chunkPtr = this.scratchOffset + CH_DATA; - let transferred = 0; - while (transferred < mapping.size) { - const remaining = mapping.size - transferred; - const toWrite = Math.min(remaining, chunkSize); - kernelMem.set(processMem.subarray(addr + transferred, addr + transferred + toWrite), chunkPtr); - const nWritten = writeChunk(mapping.segId, transferred, this.toKernelPtr(chunkPtr), toWrite); - if (nWritten <= 0) break; - transferred += nWritten; + const synced = this.withKernelCurrentPid( + channel.pid, + () => this.mergeAndRefreshSysvShmMapping(processMem, addr, mapping), + ); + if (!synced) { + this.completeChannelRaw(channel, -EIO, EIO); + this.relistenChannel(channel); + return; } - // Kernel-side detach bookkeeping - const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as (shmid: number) => number; - const result = kernelShmdt(mapping.segId); - - pidMappings.delete(addr); + const kernelShmdt = this.kernelInstance!.exports.kernel_ipc_shmdt as + (shmid: number) => number; + const result = this.withKernelCurrentPid( + channel.pid, + () => kernelShmdt(mapping.segId), + ); if (result < 0) { this.completeChannelRaw(channel, result, -result); } else { - this.completeChannelRaw(channel, 0, 0); + pidMappings.delete(addr); + if (pidMappings.size === 0) this.shmMappings.delete(channel.pid); + let unmapFailed = false; + try { + const unmap = this.runSyntheticMemorySyscall(channel, SYS_MUNMAP, [addr, mapping.size]); + if (this.hostReaped?.has(channel.pid)) return; + unmapFailed = unmap.retVal < 0; + } catch { + unmapFailed = true; + } + this.completeChannelRaw(channel, unmapFailed ? -EIO : 0, unmapFailed ? EIO : 0); } this.relistenChannel(channel); } @@ -8642,8 +14616,8 @@ export class CentralizedKernelWorker { * The notification is stored in the kernel's MqueueTable and drained here. */ private drainMqueueNotification(): void { - const drain = this.kernelInstance!.exports - .kernel_mq_drain_notification as ((outPtr: KernelPointer) => number) | undefined; + const drain = this.kernelInstance!.exports.kernel_mq_drain_notification as + ((outPtr: KernelPointer) => number) | undefined; if (!drain) return; // Use kernel scratch as output buffer for (pid: u32, signo: u32) diff --git a/host/src/kernel.ts b/host/src/kernel.ts index 19fb9f319f..fcc2cbaac6 100644 --- a/host/src/kernel.ts +++ b/host/src/kernel.ts @@ -97,7 +97,73 @@ function clampU16(value: number): number { * numeric codes from SFSError (2, 17, etc.). * Returns -EIO for unknown errors. */ -function negErrno(err: unknown): number { +const NEG_ERRNO_BY_NAME: Readonly> = { + EPERM: -1, + ENOENT: -2, + ESRCH: -3, + EINTR: -4, + EIO: -5, + ENXIO: -6, + E2BIG: -7, + ENOEXEC: -8, + EBADF: -9, + ECHILD: -10, + EAGAIN: -11, + EWOULDBLOCK: -11, + ENOMEM: -12, + EACCES: -13, + EFAULT: -14, + EBUSY: -16, + EEXIST: -17, + EXDEV: -18, + ENODEV: -19, + ENOTDIR: -20, + EISDIR: -21, + EINVAL: -22, + ENFILE: -23, + EMFILE: -24, + ENOTTY: -25, + ETXTBSY: -26, + EFBIG: -27, + ENOSPC: -28, + ESPIPE: -29, + EROFS: -30, + EMLINK: -31, + EPIPE: -32, + ERANGE: -34, + EDEADLK: -35, + ENAMETOOLONG: -36, + ENOSYS: -38, + ENOTEMPTY: -39, + ELOOP: -40, + ENOMSG: -42, + EIDRM: -43, + ENODATA: -61, + EOVERFLOW: -75, + ENOTSOCK: -88, + EDESTADDRREQ: -89, + EMSGSIZE: -90, + EPROTOTYPE: -91, + ENOPROTOOPT: -92, + EPROTONOSUPPORT: -93, + EOPNOTSUPP: -95, + ENOTSUP: -95, + EAFNOSUPPORT: -97, + EADDRINUSE: -98, + EADDRNOTAVAIL: -99, + ENETUNREACH: -101, + ECONNABORTED: -103, + ECONNRESET: -104, + EISCONN: -106, + ENOTCONN: -107, + ESHUTDOWN: -108, + ETIMEDOUT: -110, + ECONNREFUSED: -111, + EALREADY: -114, + EINPROGRESS: -115, +}; + +export function negErrno(err: unknown): number { if (err && typeof err === "object" && "code" in err) { const code = (err as { code: string | number }).code; // Numeric errno (e.g. SFSError from MemoryFileSystem/SharedFS) @@ -105,46 +171,24 @@ function negErrno(err: unknown): number { if (typeof code === "number" && code !== 0) { return code < 0 ? code : -code; } - // String error code (Node.js fs errors) - switch (code) { - case "ENOENT": return -2; - case "EACCES": return -13; - case "EPERM": return -1; - case "EEXIST": return -17; - case "ENOTDIR": return -20; - case "EISDIR": return -21; - case "EINVAL": return -22; - case "ENOSPC": return -28; - case "EROFS": return -30; - case "ENOTEMPTY": return -39; - case "ELOOP": return -40; - case "ENAMETOOLONG": return -36; - case "EBADF": return -9; - case "EMFILE": return -24; - case "ENFILE": return -23; - case "EBUSY": return -16; - case "EXDEV": return -18; - case "ENODEV": return -19; - case "EFAULT": return -14; - case "ETXTBSY": return -26; + if (typeof code === "string") { + const mapped = NEG_ERRNO_BY_NAME[code]; + if (mapped !== undefined) return mapped; + } + } + if (err && typeof err === "object" && "errno" in err) { + const errno = (err as { errno: unknown }).errno; + if (typeof errno === "number" && Number.isInteger(errno) && errno !== 0) { + return errno < 0 ? errno : -errno; } } // Check error message for errno names (e.g. plain Error("ENOENT") from DeviceFS) if (err instanceof Error) { - const msg = err.message; - if (msg.startsWith("ENOENT")) return -2; - if (msg.startsWith("EACCES")) return -13; - if (msg.startsWith("EPERM")) return -1; - if (msg.startsWith("EEXIST")) return -17; - if (msg.startsWith("ENOTDIR")) return -20; - if (msg.startsWith("EISDIR")) return -21; - if (msg.startsWith("EINVAL")) return -22; - if (msg.startsWith("ENOSPC")) return -28; - if (msg.startsWith("ENOTEMPTY")) return -39; - if (msg.startsWith("EBADF")) return -9; - if (msg.startsWith("ENOSYS")) return -38; - if (msg.startsWith("ENXIO")) return -6; - if (msg.startsWith("EXDEV")) return -18; + const name = /^([A-Z][A-Z0-9_]*)\b/.exec(err.message)?.[1]; + if (name !== undefined) { + const mapped = NEG_ERRNO_BY_NAME[name]; + if (mapped !== undefined) return mapped; + } } return -5; // EIO } @@ -211,6 +255,18 @@ export class WasmPosixKernel { private programFuncTable: WebAssembly.Table | null = null; private forkSab: SharedArrayBuffer | null = null; private waitpidSab: SharedArrayBuffer | null = null; + /** + * Extra host-handle ownership held by regular-file MAP_SHARED backings. + * The Rust kernel emits host_close only after the last guest descriptor is + * gone; a mapping retain defers that physical close until its backing is + * also released. + */ + private retainedHostFileHandles = new Map< + number, + { mappingRefs: number; descriptorClosePending: boolean } + >(); + /** Active synchronous host_fstat capture used by mmap preflight. */ + private fstatHandleCapture: { handle: number | null } | null = null; isThreadWorker = false; /** PID for this kernel instance (set by the worker) */ pid = 0; @@ -289,6 +345,64 @@ export class WasmPosixKernel { return this.kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; } + /** + * Capture the concrete host handle used by one synchronous kernel fstat. + * This lets MAP_SHARED retain the open-file capability itself instead of + * reopening a remembered pathname that may already have been unlinked. + */ + withFstatHandleCapture(operation: () => T): { + result: T; + handle: number | null; + } { + if (this.fstatHandleCapture) { + throw new Error("nested host fstat handle capture"); + } + const capture = { handle: null as number | null }; + this.fstatHandleCapture = capture; + try { + return { result: operation(), handle: capture.handle }; + } finally { + this.fstatHandleCapture = null; + } + } + + /** Retain one mapping-owned reference to an existing host file handle. */ + retainHostFileHandle(handle: number): void { + if (!Number.isSafeInteger(handle) || handle < 0) { + throw new Error(`invalid host file handle ${handle}`); + } + const retained = this.retainedHostFileHandles.get(handle); + if (retained) { + if (retained.descriptorClosePending) { + throw new Error(`cannot retain closed host file handle ${handle}`); + } + retained.mappingRefs++; + return; + } + this.retainedHostFileHandles.set(handle, { + mappingRefs: 1, + descriptorClosePending: false, + }); + } + + /** + * Release one mapping-owned reference. If the guest descriptor lifetime + * ended first, this performs the deferred physical backend close. + */ + releaseHostFileHandle(handle: number): number { + const retained = this.retainedHostFileHandles.get(handle); + if (!retained || retained.mappingRefs <= 0) return -9; // EBADF + retained.mappingRefs--; + if (retained.mappingRefs > 0) return 0; + this.retainedHostFileHandles.delete(handle); + if (!retained.descriptorClosePending) return 0; + try { + return this.io.close(handle); + } catch (e) { + return negErrno(e); + } + } + private createKernelMemory(): WebAssembly.Memory { if (this.kernelPtrWidth === 8) { return new WebAssembly.Memory({ @@ -299,11 +413,9 @@ export class WasmPosixKernel { } as unknown as WebAssembly.MemoryDescriptor); } return new WebAssembly.Memory({ - // 24 pages = 1.5 MiB of initial address space. Must be >= the kernel - // wasm's declared minimum, which the linker derives from the data - // section. The Mozilla CA bundle (~220 KiB at /etc/ssl/cert.pem) - // pushes the kernel's minimum to 20 pages; 24 leaves headroom for - // future static data without re-tuning this every time. + // 24 pages = 1.5 MiB of initial address space. This must remain above + // the kernel Wasm's linker-derived minimum and leaves headroom for + // future static data without re-tuning host construction each time. initial: 24, maximum: 16384, shared: true, @@ -500,6 +612,12 @@ export class WasmPosixKernel { host_statfs: (pathPtr: bigint, pathLen: number, statfsPtr: bigint): number => { return this.hostStatfs(Number(pathPtr), pathLen, Number(statfsPtr)); }, + host_pathconf: (pathPtr: bigint, pathLen: number, name: number, valuePtr: bigint): number => { + return this.hostPathconf(Number(pathPtr), pathLen, name, Number(valuePtr)); + }, + host_fpathconf: (handle: bigint, name: number, valuePtr: bigint): number => { + return this.hostFpathconf(handle, name, Number(valuePtr)); + }, host_mkdir: (pathPtr: bigint, pathLen: number, mode: number): number => { return this.hostMkdir(Number(pathPtr), pathLen, mode); }, @@ -527,6 +645,9 @@ export class WasmPosixKernel { host_chown: (pathPtr: bigint, pathLen: number, uid: number, gid: number): number => { return this.hostChown(Number(pathPtr), pathLen, uid, gid); }, + host_lchown: (pathPtr: bigint, pathLen: number, uid: number, gid: number): number => { + return this.hostLchown(Number(pathPtr), pathLen, uid, gid); + }, host_access: (pathPtr: bigint, pathLen: number, amode: number): number => { return this.hostAccess(Number(pathPtr), pathLen, amode); }, @@ -1089,6 +1210,12 @@ export class WasmPosixKernel { return 0; } + const retained = this.retainedHostFileHandles.get(h); + if (retained) { + retained.descriptorClosePending = true; + return 0; + } + try { return this.io.close(h); } catch (e) { @@ -1236,6 +1363,7 @@ export class WasmPosixKernel { try { const stat = this.io.fstat(h); this.writeStatToMemory(statPtr, stat); + if (this.fstatHandleCapture) this.fstatHandleCapture.handle = h; return 0; } catch (e) { return negErrno(e); @@ -1367,6 +1495,44 @@ export class WasmPosixKernel { } } + private hostPathconf( + pathPtr: number, + pathLen: number, + name: number, + valuePtr: number, + ): number { + try { + const path = this.readPathFromMemory(pathPtr, pathLen); + const value = this.io.pathconf(path, name); + this.getMemoryDataView().setBigInt64( + valuePtr, + BigInt(value ?? -1), + true, + ); + return 0; + } catch (e) { + return negErrno(e); + } + } + + private hostFpathconf( + handle: bigint, + name: number, + valuePtr: number, + ): number { + try { + const value = this.io.fpathconf(Number(handle), name); + this.getMemoryDataView().setBigInt64( + valuePtr, + BigInt(value ?? -1), + true, + ); + return 0; + } catch (e) { + return negErrno(e); + } + } + /** * host_mkdir(path_ptr, path_len, mode) -> i32 */ @@ -1526,6 +1692,24 @@ export class WasmPosixKernel { } } + /** + * host_lchown(path_ptr, path_len, uid, gid) -> i32 + */ + private hostLchown( + pathPtr: number, + pathLen: number, + uid: number, + gid: number, + ): number { + try { + const path = this.readPathFromMemory(pathPtr, pathLen); + this.io.lchown(path, uid, gid); + return 0; + } catch (e) { + return negErrno(e); + } + } + /** * host_access(path_ptr, path_len, amode) -> i32 */ diff --git a/host/src/networking/fetch-backend.ts b/host/src/networking/fetch-backend.ts index 717c3850d8..9100e16b3e 100644 --- a/host/src/networking/fetch-backend.ts +++ b/host/src/networking/fetch-backend.ts @@ -1,4 +1,8 @@ import type { NetworkIO } from "../types"; +import { + parseNumericIpv4Hostname, + validateSyntheticDnsHostname, +} from "./hostname"; /** Error with errno property for EAGAIN propagation to the kernel host imports. */ export class EagainError extends Error { @@ -10,6 +14,7 @@ const POLLIN = 0x0001; const POLLOUT = 0x0004; const POLLERR = 0x0008; const POLLHUP = 0x0010; +const MSG_PEEK = 0x0002; interface ConnectionState { hostname: string; @@ -171,7 +176,7 @@ export class FetchNetworkBackend implements NetworkIO { return data.length; } - recv(handle: number, maxLen: number, _flags: number): Uint8Array { + recv(handle: number, maxLen: number, flags: number): Uint8Array { const conn = this.connections.get(handle); if (!conn) throw new Error("ENOTCONN"); @@ -192,7 +197,9 @@ export class FetchNetworkBackend implements NetworkIO { if (len === 0) return new Uint8Array(0); const result = conn.responseBuf.slice(conn.responseOffset, conn.responseOffset + len); - conn.responseOffset += len; + if ((flags & MSG_PEEK) === 0) { + conn.responseOffset += len; + } return result; } @@ -227,6 +234,10 @@ export class FetchNetworkBackend implements NetworkIO { } getaddrinfo(hostname: string): Uint8Array { + const literalIp = parseNumericIpv4Hostname(hostname); + if (literalIp) return literalIp; + validateSyntheticDnsHostname(hostname, this.options.hostAliases); + // In the browser, return a synthetic IP. // The actual connection uses the Host header, not this IP. // Use a deterministic hash to generate a fake IP in the 10.x.x.x range. diff --git a/host/src/networking/hostname.ts b/host/src/networking/hostname.ts new file mode 100644 index 0000000000..bb46dcf04d --- /dev/null +++ b/host/src/networking/hostname.ts @@ -0,0 +1,99 @@ +const DNS_MAX_WIRE_OCTETS = 255; +const DNS_MAX_LABEL_OCTETS = 63; + +function nameNotFoundError(hostname: string): Error & { errno: number } { + return Object.assign(new Error(`ENOENT: ${hostname}`), { errno: 2 }); +} + +/** + * Parse the decimal forms accepted by inet_aton(3): a, a.b, a.b.c, and + * a.b.c.d. In the shorter forms the final component occupies all remaining + * address bits. Inputs made only of digits and dots are numeric candidates, + * so malformed or overflowing candidates fail instead of falling through to + * DNS (or wrapping when copied into a Uint8Array). + */ +export function parseNumericIpv4Hostname(hostname: string): Uint8Array | null { + if (!/^[0-9.]+$/.test(hostname)) return null; + if (!/^\d+(?:\.\d+){0,3}$/.test(hostname)) { + throw nameNotFoundError(hostname); + } + + const parts = hostname.split("."); + const widths = parts.length === 1 + ? [32n] + : parts.length === 2 + ? [8n, 24n] + : parts.length === 3 + ? [8n, 8n, 16n] + : [8n, 8n, 8n, 8n]; + + let packed = 0n; + for (let i = 0; i < parts.length; i++) { + const value = BigInt(parts[i]); + const width = widths[i]; + if (value > ((1n << width) - 1n)) { + throw nameNotFoundError(hostname); + } + packed = (packed << width) | value; + } + + return new Uint8Array([ + Number((packed >> 24n) & 0xffn), + Number((packed >> 16n) & 0xffn), + Number((packed >> 8n) & 0xffn), + Number(packed & 0xffn), + ]); +} + +/** + * Validate an ASCII DNS hostname at the DNS wire-format boundary. A single + * trailing dot is the root label and remains part of the caller's hostname; + * it is removed only while checking the preceding labels. + */ +export function validateDnsHostname(hostname: string): void { + const absoluteName = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; + if (absoluteName.length === 0 || !/^[\x00-\x7f]+$/.test(absoluteName)) { + throw nameNotFoundError(hostname); + } + + let wireOctets = 1; // Terminal root label. + for (const label of absoluteName.split(".")) { + if ( + label.length === 0 || + label.length > DNS_MAX_LABEL_OCTETS || + !/^[A-Za-z0-9](?:[A-Za-z0-9-]*[A-Za-z0-9])?$/.test(label) + ) { + throw nameNotFoundError(hostname); + } + wireOctets += 1 + label.length; + } + + if (wireOctets > DNS_MAX_WIRE_OCTETS) { + throw nameNotFoundError(hostname); + } +} + +/** + * Validate a name before a browser fetch/TLS backend assigns it a synthetic + * address. These backends defer the real lookup to fetch(), so they must reject + * names that the browser environment already knows cannot resolve. + */ +export function validateSyntheticDnsHostname( + hostname: string, + aliases?: Record, +): void { + validateDnsHostname(hostname); + + const absoluteName = hostname.endsWith(".") ? hostname.slice(0, -1) : hostname; + const lowerName = absoluteName.toLowerCase(); + if ( + aliases && + (Object.prototype.hasOwnProperty.call(aliases, absoluteName) || + Object.prototype.hasOwnProperty.call(aliases, lowerName)) + ) { + return; + } + if (lowerName === "invalid" || lowerName.endsWith(".invalid")) { + throw nameNotFoundError(hostname); + } +} diff --git a/host/src/networking/tcp-backend.ts b/host/src/networking/tcp-backend.ts index 637af9ea39..cd69e2030b 100644 --- a/host/src/networking/tcp-backend.ts +++ b/host/src/networking/tcp-backend.ts @@ -2,11 +2,13 @@ import * as net from "net"; import type { NetworkIO } from "../types"; import { lookup } from "dns"; import { EagainError } from "./fetch-backend"; +import { parseNumericIpv4Hostname, validateDnsHostname } from "./hostname"; const POLLIN = 0x0001; const POLLOUT = 0x0004; const POLLERR = 0x0008; const POLLHUP = 0x0010; +const MSG_PEEK = 0x0002; /** * Map a Node.js network error code to a POSIX errno value. @@ -52,6 +54,7 @@ interface Connection { socket: net.Socket; recvBuf: Buffer; closed: boolean; + readEnded: boolean; /** True once net.Socket has emitted 'connect' (TCP handshake done). */ connected: boolean; error: Error | null; @@ -68,11 +71,12 @@ export class TcpNetworkBackend implements NetworkIO { connect(handle: number, addr: Uint8Array, port: number): void { const ip = `${addr[0]}.${addr[1]}.${addr[2]}.${addr[3]}`; - const socket = new net.Socket(); + const socket = new net.Socket({ allowHalfOpen: true }); const conn: Connection = { socket, recvBuf: Buffer.alloc(0), closed: false, + readEnded: false, connected: false, error: null, }; @@ -83,11 +87,15 @@ export class TcpNetworkBackend implements NetworkIO { socket.on("data", (data: Buffer) => { conn.recvBuf = Buffer.concat([conn.recvBuf, data]); }); + socket.on("end", () => { + conn.readEnded = true; + }); socket.on("error", (err: Error) => { conn.error = err; }); socket.on("close", () => { conn.closed = true; + conn.readEnded = true; }); socket.connect(port, ip); @@ -115,14 +123,23 @@ export class TcpNetworkBackend implements NetworkIO { const conn = this.connections.get(handle); if (!conn) throw new Error("ENOTCONN"); if (conn.error) throw conn.error; - if (conn.closed) throw new Error("EPIPE"); + if ( + conn.closed || + conn.socket.destroyed || + conn.socket.writableEnded || + !conn.socket.writable + ) { + throw Object.assign(new Error("EPIPE"), { code: "EPIPE", errno: 32 }); + } // `net.Socket.write` buffers internally before the TCP handshake - // completes, so we don't need to gate on `connected`. + // completes, so we don't need to gate on `connected`. With allowHalfOpen, + // this also permits writes after a peer FIN while Node still has an open + // writable half, matching TCP half-close semantics. conn.socket.write(Buffer.from(data)); return data.length; } - recv(handle: number, maxLen: number, _flags: number): Uint8Array { + recv(handle: number, maxLen: number, flags: number): Uint8Array { const conn = this.connections.get(handle); if (!conn) throw new Error("ENOTCONN"); if (conn.error) throw conn.error; @@ -134,11 +151,13 @@ export class TcpNetworkBackend implements NetworkIO { conn.recvBuf.byteOffset, len, ); - conn.recvBuf = conn.recvBuf.subarray(len); + if ((flags & MSG_PEEK) === 0) { + conn.recvBuf = conn.recvBuf.subarray(len); + } return result; } - if (conn.closed) return new Uint8Array(0); + if (conn.readEnded || conn.closed) return new Uint8Array(0); throw new EagainError(); } @@ -150,13 +169,20 @@ export class TcpNetworkBackend implements NetworkIO { if (conn.error) return POLLERR; let revents = 0; - if ((events & POLLIN) !== 0 && conn.recvBuf.length > 0) { + if ((events & POLLIN) !== 0 && (conn.recvBuf.length > 0 || conn.readEnded || conn.closed)) { revents |= POLLIN; } if (conn.closed) { revents |= POLLHUP; } - if ((events & POLLOUT) !== 0 && conn.connected && !conn.closed) { + if ( + (events & POLLOUT) !== 0 && + conn.connected && + !conn.closed && + !conn.socket.destroyed && + !conn.socket.writableEnded && + conn.socket.writable + ) { revents |= POLLOUT; } return revents; @@ -165,12 +191,22 @@ export class TcpNetworkBackend implements NetworkIO { close(handle: number): void { const conn = this.connections.get(handle); if (conn) { - conn.socket.destroy(); + // destroySoon() ends the writable half, flushes queued bytes, and only + // then releases the Node handle. The operating system retains whatever + // TCP close state is needed; no timer or fabricated post-FIN write count + // is imposed here. + if (!conn.socket.destroyed) { + conn.socket.destroySoon(); + } this.connections.delete(handle); } } getaddrinfo(hostname: string): Uint8Array { + const literalIp = parseNumericIpv4Hostname(hostname); + if (literalIp) return literalIp; + validateDnsHostname(hostname); + // Atomics.wait would deadlock libuv's dns.lookup callback on the kernel // thread — same shape as connect/recv. Kick off async, throw EAGAIN, // pick up the cached result on the worker's next retry. diff --git a/host/src/networking/tls-network-backend.ts b/host/src/networking/tls-network-backend.ts index 53eab9d98b..fe48172346 100644 --- a/host/src/networking/tls-network-backend.ts +++ b/host/src/networking/tls-network-backend.ts @@ -15,6 +15,10 @@ import type { NetworkIO } from "../types"; import { EagainError } from "./fetch-backend"; +import { + parseNumericIpv4Hostname, + validateSyntheticDnsHostname, +} from "./hostname"; import { TLS_1_2_Connection } from "../../../packages/registry/openssl/src/tls/1_2/connection"; import { generateCertificate, @@ -22,6 +26,12 @@ import { type GeneratedCertificate, } from "../../../packages/registry/openssl/src/tls/certificates"; +const POLLIN = 0x0001; +const POLLOUT = 0x0004; +const POLLERR = 0x0008; +const POLLHUP = 0x0010; +const MSG_PEEK = 0x0002; + // ------------------------------------------------------------------ types interface HttpConnectionState { @@ -229,6 +239,10 @@ export class TlsNetworkBackend implements NetworkIO { // ---- NetworkIO implementation ---- getaddrinfo(hostname: string): Uint8Array { + const literalIp = parseNumericIpv4Hostname(hostname); + if (literalIp) return literalIp; + validateSyntheticDnsHostname(hostname, this.dnsAliases); + const ip = this.syntheticIp(hostname); const ipStr = this.ipKey(ip); this.hostnameMap.set(ipStr, hostname); @@ -270,14 +284,14 @@ export class TlsNetworkBackend implements NetworkIO { return this.httpSend(conn, data); } - recv(handle: number, maxLen: number, _flags: number): Uint8Array { + recv(handle: number, maxLen: number, flags: number): Uint8Array { const conn = this.connections.get(handle); if (!conn) throw new Error("ENOTCONN"); if (conn.kind === "tls") { - return this.tlsRecv(conn, maxLen); + return this.tlsRecv(conn, maxLen, flags); } - return this.httpRecv(conn, maxLen); + return this.httpRecv(conn, maxLen, flags); } close(handle: number): void { @@ -417,14 +431,16 @@ export class TlsNetworkBackend implements NetworkIO { return data.length; } - private tlsRecv(conn: TlsConnectionState, maxLen: number): Uint8Array { + private tlsRecv(conn: TlsConnectionState, maxLen: number, flags: number): Uint8Array { if (conn.error) throw conn.error; // Check if we have encrypted data buffered from the TLS engine if (conn.clientDownstreamBuf.length > 0) { const n = Math.min(maxLen, conn.clientDownstreamBuf.length); const result = conn.clientDownstreamBuf.slice(0, n); - conn.clientDownstreamBuf = conn.clientDownstreamBuf.subarray(n); + if ((flags & MSG_PEEK) === 0) { + conn.clientDownstreamBuf = conn.clientDownstreamBuf.subarray(n); + } return result; } @@ -615,7 +631,7 @@ export class TlsNetworkBackend implements NetworkIO { return data.length; } - private httpRecv(conn: HttpConnectionState, maxLen: number): Uint8Array { + private httpRecv(conn: HttpConnectionState, maxLen: number, flags: number): Uint8Array { if (!conn.fetchDone) { throw new EagainError(); } @@ -631,10 +647,50 @@ export class TlsNetworkBackend implements NetworkIO { if (len === 0) return new Uint8Array(0); const result = conn.responseBuf.slice(conn.responseOffset, conn.responseOffset + len); - conn.responseOffset += len; + if ((flags & MSG_PEEK) === 0) { + conn.responseOffset += len; + } return result; } + poll(handle: number, events: number): number { + const conn = this.connections.get(handle); + if (!conn) throw Object.assign(new Error("ENOTCONN"), { errno: 107 }); + + let revents = 0; + if ((events & POLLOUT) !== 0 && (conn.kind === "http" || !conn.closed)) { + revents |= POLLOUT; + } + + if (conn.kind === "http") { + if (conn.fetchError) return revents | POLLERR; + if ( + (events & POLLIN) !== 0 && + conn.responseBuf && + conn.responseOffset < conn.responseBuf.length + ) { + revents |= POLLIN; + } + if ( + conn.fetchDone && + conn.responseBuf && + conn.responseOffset >= conn.responseBuf.length + ) { + revents |= POLLHUP; + } + return revents; + } + + if (conn.error) return revents | POLLERR; + if ((events & POLLIN) !== 0 && conn.clientDownstreamBuf.length > 0) { + revents |= POLLIN; + } + if (conn.closed && conn.clientDownstreamBuf.length === 0) { + revents |= POLLHUP; + } + return revents; + } + // ---- Utilities ---- private syntheticIp(hostname: string): Uint8Array { diff --git a/host/src/networking/virtual-network.ts b/host/src/networking/virtual-network.ts index 68560eeb71..93a8ccd43a 100644 --- a/host/src/networking/virtual-network.ts +++ b/host/src/networking/virtual-network.ts @@ -7,6 +7,7 @@ import type { UdpReceiveTarget, } from "../types"; import { EagainError } from "./fetch-backend"; +import { parseNumericIpv4Hostname, validateDnsHostname } from "./hostname"; const EADDRINUSE = 98; const EADDRNOTAVAIL = 99; @@ -20,6 +21,7 @@ const POLLIN = 0x0001; const POLLOUT = 0x0004; const POLLERR = 0x0008; const POLLHUP = 0x0010; +const MSG_PEEK = 0x0002; const ANY = "0.0.0.0"; @@ -51,8 +53,11 @@ class VirtualTcpPeer implements TcpConnectionPeer { private peer?: VirtualTcpPeer; private readClosed = false; private writeClosed = false; + private orphanedReceive = false; private reset = false; + constructor(private onRelease?: (peer: VirtualTcpPeer) => void) {} + pairWith(peer: VirtualTcpPeer): void { this.peer = peer; } @@ -68,16 +73,32 @@ class VirtualTcpPeer implements TcpConnectionPeer { err.errno = ECONNRESET; throw err; } - if (this.writeClosed || !this.peer || this.peer.readClosed || this.peer.reset) { + if (this.writeClosed || !this.peer) { const err = new Error("EPIPE") as Error & { errno?: number }; err.errno = 32; throw err; } + if (this.peer.reset) { + const err = new Error("ECONNRESET") as Error & { errno?: number }; + err.errno = ECONNRESET; + throw err; + } + if (this.peer.readClosed) { + const err = new Error("EPIPE") as Error & { errno?: number }; + err.errno = 32; + throw err; + } + // Normal TCP close is simplex: the peer's FIN closes its write half while + // its orphaned receive half continues to consume packets. Keep an explicit + // discard sink rather than inventing a fixed successful-write count. + if (this.peer.orphanedReceive) { + return data.length; + } this.peer.enqueue(data.slice()); return data.length; } - recv(maxLen: number, _flags: number): Uint8Array { + recv(maxLen: number, flags: number): Uint8Array { if (this.reset) { const err = new Error("ECONNRESET") as Error & { errno?: number }; err.errno = ECONNRESET; @@ -86,7 +107,9 @@ class VirtualTcpPeer implements TcpConnectionPeer { if (this.recvBuf.length > 0) { const len = Math.min(maxLen, this.recvBuf.length); const out = this.recvBuf.slice(0, len); - this.recvBuf = this.recvBuf.slice(len); + if ((flags & MSG_PEEK) === 0) { + this.recvBuf = this.recvBuf.slice(len); + } return out; } if (!this.peer || this.peer.writeClosed) { @@ -127,13 +150,32 @@ class VirtualTcpPeer implements TcpConnectionPeer { } close(): void { - this.shutdown(2); + this.writeClosed = true; + this.orphanedReceive = true; + this.recvBuf = new Uint8Array(0); + this.releaseFromOwner(); + } + + abort(): void { + this.readClosed = true; + this.writeClosed = true; + this.orphanedReceive = false; + this.reset = true; + this.recvBuf = new Uint8Array(0); + this.releaseFromOwner(); + this.peer?.resetPeer(); } resetPeer(): void { this.reset = true; this.recvBuf = new Uint8Array(0); } + + private releaseFromOwner(): void { + const onRelease = this.onRelease; + this.onRelease = undefined; + onRelease?.(this); + } } interface TcpListener { @@ -204,17 +246,16 @@ export class LocalVirtualNetwork { this.tcpListeners = this.tcpListeners.filter((l) => l.machineId !== machineId); this.udpEndpoints = this.udpEndpoints.filter((e) => e.machineId !== machineId); for (const peer of this.tcpPeersByMachine.get(machineId) ?? []) { - peer.close(); + peer.abort(); } this.tcpPeersByMachine.delete(machineId); backend.resetAllConnections(); } resolve(hostname: string): Uint8Array | null { - const direct = hostname.match(/^(\d+)\.(\d+)\.(\d+)\.(\d+)$/); - if (direct) { - return new Uint8Array(direct.slice(1).map((part) => Number(part))); - } + const direct = parseNumericIpv4Hostname(hostname); + if (direct) return direct; + validateDnsHostname(hostname); const addr = this.hostnames.get(hostname); return addr ? copyAddr(addr) : null; } @@ -272,12 +313,10 @@ export class LocalVirtualNetwork { return { peer: new VirtualTcpPeer(), status: ECONNREFUSED }; } - const client = new VirtualTcpPeer(); - const server = new VirtualTcpPeer(); + const client = this.createTcpPeer(sourceMachineId); + const server = this.createTcpPeer(targetMachineId); client.pairWith(server); server.pairWith(client); - this.trackTcpPeer(sourceMachineId, client); - this.trackTcpPeer(targetMachineId, server); const accepted = listener.target.accept( server, { addr: copyAddr(destination.addr), port: destination.port }, @@ -357,6 +396,16 @@ export class LocalVirtualNetwork { } peers.add(peer); } + + private createTcpPeer(machineId: string): VirtualTcpPeer { + const peer = new VirtualTcpPeer((released) => { + const peers = this.tcpPeersByMachine.get(machineId); + peers?.delete(released); + if (peers?.size === 0) this.tcpPeersByMachine.delete(machineId); + }); + this.trackTcpPeer(machineId, peer); + return peer; + } } export class VirtualNetworkBackend implements NetworkIO { @@ -450,7 +499,7 @@ export class VirtualNetworkBackend implements NetworkIO { } resetAllConnections(): void { - for (const conn of this.connections.values()) conn.close(); + for (const conn of this.connections.values()) conn.abort(); this.connections.clear(); this.connectErrors.clear(); } diff --git a/host/src/node-kernel-host.ts b/host/src/node-kernel-host.ts index a2fe31f688..f68df90990 100644 --- a/host/src/node-kernel-host.ts +++ b/host/src/node-kernel-host.ts @@ -20,6 +20,7 @@ import { createRequire } from "node:module"; import { Worker as NodeThreadWorker } from "node:worker_threads"; import { resolveBinary } from "./binary-resolver"; import type { + HostDiagnostic, MainToKernelMessage, KernelToMainMessage, ResolveExecRequestMessage, @@ -61,11 +62,14 @@ export interface NodeKernelHostOptions { onStdout?: (pid: number, data: Uint8Array) => void; /** Called when a process writes to stderr */ onStderr?: (pid: number, data: Uint8Array) => void; + /** Called for host-runtime diagnostics that are not guest stderr. */ + onHostDiagnostic?: (diagnostic: HostDiagnostic) => void; /** Called when a process writes PTY output */ onPtyOutput?: (pid: number, data: Uint8Array) => void; /** Called when a process is spawned, execs a new program, or exits. * Used by Inspector-style UIs to refresh their process table without - * polling. */ + * polling. Kernel-internal fork and posix_spawn events carry `ppid`; + * the synthetic root spawn does not. */ onProcessEvent?: (event: { kind: "spawn" | "exec" | "exit"; pid: number; ppid?: number; exitStatus?: number }) => void; /** * Called when the worker can't resolve an exec path locally. @@ -88,7 +92,15 @@ export interface NodeKernelHostOptions { * to a VFS-only world yet. */ rootfsImage?: "default" | ArrayBuffer | Uint8Array; - extraMounts?: Array<{ mountPoint: string; hostPath: string; readonly?: boolean }>; + extraMounts?: Array<{ + mountPoint: string; + hostPath: string; + readonly?: boolean; + /** Virtual owner for existing host-backed mount entries. Defaults to root. */ + uid?: number; + /** Virtual group for existing host-backed mount entries. Defaults to root. */ + gid?: number; + }>; } export interface SpawnOptions { @@ -144,6 +156,20 @@ export class NodeKernelHost { reject(error); } this.pendingRequests.clear(); + const diagnostic: HostDiagnostic = { + pid: 0, + source: "kernel worker", + message: `[NodeKernelHost] kernel worker error: ${error.message}`, + }; + // A worker-level error cannot send a typed message itself. Preserve the + // same callback contract and a visible default without treating the + // failure as guest stderr. + console.error(diagnostic.message); + try { + this.options.onHostDiagnostic?.(diagnostic); + } catch (callbackError) { + console.error("[NodeKernelHost] onHostDiagnostic callback failed:", callbackError); + } }); // Send init and wait for ready @@ -480,6 +506,10 @@ export class NodeKernelHost { private handleWorkerMessage(msg: KernelToMainMessage): void { switch (msg.type) { + case "ready": + // The temporary init listener resolves readiness. The permanent + // listener also receives the message, so account for it explicitly. + break; case "response": { const pending = this.pendingRequests.get(msg.requestId); if (pending) { @@ -515,7 +545,10 @@ export class NodeKernelHost { // Kernel-internal fork / exec / posix_spawn. The host doesn't // see these via NodeKernelHost.spawn (forks happen inside the // wasm kernel without going through the request/response loop). - this.options.onProcessEvent?.({ kind: msg.kind, pid: msg.pid, ppid: msg.ppid }); + const event = msg.kind === "spawn" + ? { kind: msg.kind, pid: msg.pid, ppid: msg.ppid } + : { kind: msg.kind, pid: msg.pid }; + this.options.onProcessEvent?.(event); break; } case "stdout": @@ -524,12 +557,32 @@ export class NodeKernelHost { case "stderr": this.options.onStderr?.(msg.pid, msg.data); break; + case "host_diagnostic": { + this.options.onHostDiagnostic?.({ + pid: msg.pid, + source: msg.source, + message: msg.message, + ...(msg.status === undefined ? {} : { status: msg.status }), + }); + break; + } case "pty_output": this.options.onPtyOutput?.(msg.pid, msg.data); break; case "resolve_exec": this.handleResolveExec(msg); break; + default: { + // Keep this dispatch coupled to KernelToMainMessage as the protocol + // grows. Runtime values still originate outside TypeScript, so make a + // malformed/unknown worker message visible instead of dropping it. + const exhaustive: never = msg; + void exhaustive; + console.error( + `[NodeKernelHost] unknown kernel-worker message type: ${String((msg as { type?: unknown }).type)}`, + ); + break; + } } } @@ -574,8 +627,8 @@ function resolveRootfsImage( ): ArrayBuffer | null { if (override === undefined) return null; if (override === "default") { - const path = resolveRootfsArtifact(); - const buf = readFileSync(path); + const artifact = resolveRootfsArtifact(); + const buf = readFileSync(artifact.selectedPath); return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } if (override instanceof Uint8Array) { @@ -588,12 +641,25 @@ function resolveRootfsImage( return override; } -function resolveRootfsArtifact(): string { +export interface ResolvedRootfsArtifact { + resolverRequest: "rootfs.vfs" | "programs/rootfs.vfs"; + selectedPath: string; +} + +export function resolveRootfsArtifact( + resolver: (request: string) => string = resolveBinary, +): ResolvedRootfsArtifact { try { - return resolveBinary("rootfs.vfs"); + return { + resolverRequest: "rootfs.vfs", + selectedPath: resolver("rootfs.vfs"), + }; } catch (rootfsError) { try { - return resolveBinary("programs/rootfs.vfs"); + return { + resolverRequest: "programs/rootfs.vfs", + selectedPath: resolver("programs/rootfs.vfs"), + }; } catch (programsError) { const rootfsMessage = rootfsError instanceof Error ? rootfsError.message : String(rootfsError); const programsMessage = programsError instanceof Error ? programsError.message : String(programsError); diff --git a/host/src/node-kernel-protocol.ts b/host/src/node-kernel-protocol.ts index 853fc19b49..51c0625823 100644 --- a/host/src/node-kernel-protocol.ts +++ b/host/src/node-kernel-protocol.ts @@ -11,8 +11,10 @@ * See docs/plans/2026-04-30-external-kernel-http-request-interface.md. */ import type { HttpRequest, HttpResponse } from "./networking/in-kernel-http"; +import type { HostDiagnosticMessage } from "./host-diagnostic"; export type { HttpRequest, HttpResponse }; +export type { HostDiagnostic } from "./host-diagnostic"; // ── Main Thread → Kernel Worker ── @@ -37,7 +39,13 @@ export interface InitMessage { * (custom-io / legacy path). */ rootfsImage?: ArrayBuffer; - extraMounts?: Array<{ mountPoint: string; hostPath: string; readonly?: boolean }>; + extraMounts?: Array<{ + mountPoint: string; + hostPath: string; + readonly?: boolean; + uid?: number; + gid?: number; + }>; /** Attach a real-TCP backend (TcpNetworkBackend) to the worker's PlatformIO * so wasm programs can dial external hosts via Node `net.Socket`. */ enableTcpNetwork?: boolean; @@ -245,14 +253,12 @@ export interface ResolveExecRequestMessage { /** * Posted whenever the kernel forks, execs, or posix_spawns. Mirrors the * browser-side ProcEventMessage. Exit events come via the existing - * ExitMessage; we don't duplicate them here. + * ExitMessage; we don't duplicate them here. Spawn events always carry the + * authoritative parent pid; exec events preserve process identity and do not. */ -export interface ProcEventMessage { - type: "proc_event"; - kind: "spawn" | "exec"; - pid: number; - ppid?: number; -} +export type ProcEventMessage = + | { type: "proc_event"; kind: "spawn"; pid: number; ppid: number } + | { type: "proc_event"; kind: "exec"; pid: number }; export type KernelToMainMessage = | ReadyMessage @@ -260,6 +266,7 @@ export type KernelToMainMessage = | ExitMessage | StdoutMessage | StderrMessage + | HostDiagnosticMessage | PtyOutputMessage | ResolveExecRequestMessage | ProcEventMessage; diff --git a/host/src/node-kernel-worker-entry.ts b/host/src/node-kernel-worker-entry.ts index 7775ddfa79..046fa4e21f 100644 --- a/host/src/node-kernel-worker-entry.ts +++ b/host/src/node-kernel-worker-entry.ts @@ -10,15 +10,20 @@ * Main → Worker: init, spawn, append_stdin_data, set_stdin_data, * pty_write, pty_resize, terminate_process, destroy, * resolve_exec_response - * Worker → Main: ready, response, exit, stdout, stderr, pty_output, - * resolve_exec + * Worker → Main: ready, response, exit, stdout, stderr, host_diagnostic, + * pty_output, resolve_exec */ import { parentPort } from "node:worker_threads"; import { readFileSync, existsSync, mkdtempSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; -import { CAPTURED_STDIO, CentralizedKernelWorker, TERMINAL_STDIO } from "./kernel-worker"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + isCurrentProcessGeneration, + TERMINAL_STDIO, +} from "./kernel-worker"; import type { ForkFromThreadContext, ResolvedSpawnProgram, @@ -39,10 +44,11 @@ import type { MountConfig } from "./vfs/types"; import { TcpNetworkBackend } from "./networking/tcp-backend"; import { findRepoRoot } from "./binary-resolver"; import { NodeWorkerAdapter } from "./worker-adapter"; +import { DeferredWorkerHandle } from "./deferred-worker-handle"; import { ThreadPageAllocator } from "./thread-allocator"; import { patchWasmForThread } from "./worker-main"; import { ThreadExitCoordinator } from "./thread-exit-coordinator"; -import { detectPtrWidth, extractHeapBase, isWasmModuleBytes } from "./constants"; +import { detectPtrWidth, extractAbiVersion, extractHeapBase, isWasmModuleBytes } from "./constants"; import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, PAGES_PER_THREAD, WASM_PAGE_SIZE } from "./constants"; import { classifiedSignalOrFallback, @@ -50,7 +56,11 @@ import { signalExitStatus, SIGSEGV, } from "./trap-signals"; -import { threadWorkerFailureDisposition } from "./thread-worker-disposition"; +import { + removeThreadWorkerRegistryEntry, + threadWorkerFailureDisposition, +} from "./thread-worker-disposition"; +import { VmInterruptTimerManager } from "./vm-interrupt-timer"; import { computeProcessMemoryLayout, createProcessMemory, @@ -65,6 +75,7 @@ import type { WorkerToHostMessage, } from "./worker-protocol"; import type { + HostDiagnostic, MainToKernelMessage, KernelToMainMessage, InitMessage, @@ -95,8 +106,6 @@ const ENOEXEC = 8; // [JSC-TERMINATE-ATOMICS-WAIT-LEAK] destroy-time drain bounds; see handleDestroy. const DESTROY_KILL_DRAIN_TIMEOUT_MS = 1500; const DESTROY_KILL_DRAIN_POLL_MS = 15; -const SSL_CERT_FILE_PATH = "/etc/ssl/certs/ca-certificates.crt"; -const OPENSSL_DEFAULT_CERT_FILE_PATH = "/etc/ssl/cert.pem"; // Process tracking interface ForkReplayContext { @@ -118,8 +127,12 @@ interface ProcessInfo { forkReplayContext?: ForkReplayContext; } const processes = new Map(); -const processTeardowns = new Map>(); +const processTeardowns = new Map>(); +const vmInterruptTimers = new VmInterruptTimerManager( + (pid) => processes.get(pid), +); const reportedExits = new Set(); +const reportedNonzeroProcessExits = new Set(); // Workers terminated by the kernel-worker entry itself (handleExit / // handleExec / handleTerminate). The crash safety-net listener checks @@ -145,11 +158,16 @@ function installCrashSafetyNet( if (intentionallyTerminated.has(worker as object)) return; const cur = processes.get(pid); if (!cur || cur.worker !== worker) return; // already torn down or replaced - const errBytes = new TextEncoder().encode( - `[process-worker] pid=${pid} crashed (worker exit code=${code}, no SYS_exit_group from wasm)\n`, - ); - post({ type: "stderr", pid, data: errBytes }); - void finalizeProcessWorker(pid, worker, signalExitStatus(SIGSEGV), SIGSEGV); + const status = signalExitStatus(SIGSEGV); + reportHostDiagnostic({ + pid, + status, + source: "worker exit event", + message: + `[process-worker] pid=${pid} crashed ` + + `(worker exit code=${code}, no SYS_exit_group from wasm)`, + }); + void finalizeProcessWorker(pid, worker, status, SIGSEGV); }); } @@ -184,6 +202,9 @@ async function terminateThreadWorkers(pid: number): Promise { const threads = threadWorkers.get(pid); if (!threads) return; threadWorkers.delete(pid); + for (const thread of threads) { + intentionallyTerminated.add(thread.worker as object); + } for (const t of threads) { await (t.termination ?? terminateTrackedWorker(t.worker)); threadExits.release(pid, t.channelOffset); @@ -193,9 +214,28 @@ async function terminateThreadWorkers(pid: number): Promise { function reportProcessExit(pid: number, status: number): void { if (reportedExits.has(pid)) return; reportedExits.add(pid); + if (status !== 0 && !reportedNonzeroProcessExits.has(pid)) { + reportedNonzeroProcessExits.add(pid); + reportHostDiagnostic({ + pid, + status, + source: "process exit", + message: `[node-kernel-worker] nonzero process exit pid=${pid} status=${status}`, + }, "warn"); + } post({ type: "exit", pid, status }); } +function handleVmInterruptTimer(msg: { + pid: number; + timedOutPtr: number; + vmInterruptPtr: number; + seconds: number; +}, pid: number, process: ProcessInfo): void { + if (msg.pid !== pid) return; + vmInterruptTimers.handleRequest(pid, process, msg); +} + function signalFromExitStatus(exitStatus: number): number | null { return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null; } @@ -230,24 +270,30 @@ async function finalizeProcessWorker( exitStatus: number, crashSignum: number = signalFromExitStatus(exitStatus) ?? SIGSEGV, ): Promise { + if (intentionallyTerminated.has(worker as object)) return; const cur = processes.get(pid); - if (cur && cur.worker === worker) { - // Synthesize a signal-style reap *before* `deactivateProcess` in - // case the worker died without sending SYS_EXIT_GROUP (uncaught - // wasm trap, instantiation failure → `{type:"error"}` path). - // Without this, a concurrent waitpid in the parent blocks until - // destroy because the kernel never marked the child as a zombie. - // Idempotent via `hostReaped`: when the kernel already processed - // a clean SYS_EXIT_GROUP for this pid, this is a no-op. - try { kernelWorker.notifyHostProcessCrashed(pid, crashSignum); } catch { /* best-effort */ } - try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } - processes.delete(pid); - threadModuleCache.delete(pid); - ptyByPid.delete(pid); - await terminateThreadWorkers(pid); - await terminateTrackedWorker(worker); - } + if (!cur || cur.worker !== worker) return; + vmInterruptTimers.clear(pid); + + // Synthesize a signal-style reap *before* `deactivateProcess` in + // case the worker died without sending SYS_EXIT_GROUP (uncaught + // wasm trap, instantiation failure → `{type:"error"}` path). + // Without this, a concurrent waitpid in the parent blocks until + // destroy because the kernel never marked the child as a zombie. + // Idempotent via `hostReaped`: when the kernel already processed + // a clean SYS_EXIT_GROUP for this pid, this is a no-op. + try { kernelWorker.notifyHostProcessCrashed(pid, crashSignum); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } + processes.delete(pid); + threadModuleCache.delete(pid); + ptyByPid.delete(pid); + + // Report while this worker is still known to be the current generation. + // Its asynchronous termination must not report an exit for an exec + // replacement that has since reused the pid. reportProcessExit(pid, exitStatus); + await terminateThreadWorkers(pid); + await terminateTrackedWorker(worker); } function processWorkerErrorDisposition(reason: string | undefined): { @@ -273,9 +319,15 @@ function finalizeProcessWorkerError( worker: ReturnType, message: string | undefined, ): void { - const errBytes = new TextEncoder().encode(`[process-worker] ${message ?? "unknown error"}\n`); - post({ type: "stderr", pid, data: errBytes }); + if (intentionallyTerminated.has(worker as object)) return; + if (processes.get(pid)?.worker !== worker) return; const { exitStatus, signum } = processWorkerErrorDisposition(message); + reportHostDiagnostic({ + pid, + status: exitStatus, + source: "worker-main error message", + message: `[process-worker] ${message ?? "unknown error"}`, + }); void finalizeProcessWorker(pid, worker, exitStatus, signum); } @@ -285,10 +337,16 @@ function finalizeUnexpectedWorkerError( label: string, err: unknown, ): void { + if (intentionallyTerminated.has(worker as object)) return; + if (processes.get(pid)?.worker !== worker) return; const message = err instanceof Error ? (err.message ?? String(err)) : String(err); - const errBytes = new TextEncoder().encode(`[kernel-worker] pid=${pid}: ${label}: ${message}\n`); - post({ type: "stderr", pid, data: errBytes }); const { exitStatus, signum } = unexpectedWorkerCrashDisposition(err); + reportHostDiagnostic({ + pid, + status: exitStatus, + source: label, + message: `[kernel-worker] pid=${pid}: ${label}: ${message}`, + }); void finalizeProcessWorker(pid, worker, exitStatus, signum); } @@ -296,6 +354,23 @@ function post(msg: KernelToMainMessage) { port.postMessage(msg); } +function reportHostDiagnostic( + diagnostic: HostDiagnostic, + level: "error" | "warn" = "error", +): void { + if (level === "warn") console.warn(diagnostic.message); + else console.error(diagnostic.message); + post({ type: "host_diagnostic", ...diagnostic }); +} + +function reportWorkerProtocolError(message: string): void { + reportHostDiagnostic({ + pid: 0, + source: "worker protocol", + message: `[node-kernel-worker] ${message}`, + }); +} + function respond(requestId: number, result: unknown) { post({ type: "response", requestId, result }); } @@ -492,7 +567,18 @@ async function resolveExecutableForLaunch( const shebang = parseShebang(bytes); if (!shebang) { if (!isWasmModuleBytes(bytes)) return { errno: ENOEXEC }; - return { programBytes: bytes, argv }; + let programModule: WebAssembly.Module; + try { + programModule = await WebAssembly.compile(bytes); + } catch (error) { + if (error instanceof WebAssembly.CompileError) return { errno: ENOEXEC }; + throw error; + } + const declaredAbi = extractAbiVersion(bytes); + if (declaredAbi !== null && declaredAbi !== kernelWorker.getKernelAbiVersion()) { + return { errno: ENOEXEC }; + } + return { programBytes: bytes, programModule, argv }; } const scriptArgv = [ @@ -506,36 +592,6 @@ async function resolveExecutableForLaunch( // --- Init --- -function writeMemfsFile(fs: MemoryFileSystem, path: string, bytes: Uint8Array): void { - const fd = fs.open(path, 0o1101, 0o644); - try { - fs.write(fd, bytes, 0, bytes.byteLength); - } finally { - fs.close(fd); - } -} - -function installDefaultCaBundle(fs: MemoryFileSystem): void { - const certPath = join(findRepoRoot(), "packages", "registry", "openssl", "cacert.pem"); - let certBytes: Uint8Array; - try { - certBytes = readFileSync(certPath); - } catch (e) { - console.error("[node-kernel-worker] Failed to read default CA bundle:", e); - return; - } - - try { - for (const dir of ["/etc", "/etc/ssl", "/etc/ssl/certs"]) { - try { fs.mkdir(dir, 0o755); } catch { /* exists */ } - } - writeMemfsFile(fs, SSL_CERT_FILE_PATH, certBytes); - writeMemfsFile(fs, OPENSSL_DEFAULT_CERT_FILE_PATH, certBytes); - } catch (e) { - console.error("[node-kernel-worker] Failed to write default CA bundle to VFS:", e); - } -} - /** * Materialise the default mount spec into a `VirtualPlatformIO` backed by * the rootfs image at `/` and per-boot host-fs scratch dirs everywhere @@ -544,7 +600,13 @@ function installDefaultCaBundle(fs: MemoryFileSystem): void { */ function buildVirtualPlatformIO( rootfsImage: ArrayBuffer, - extraMounts?: Array<{ mountPoint: string; hostPath: string; readonly?: boolean }>, + extraMounts?: Array<{ + mountPoint: string; + hostPath: string; + readonly?: boolean; + uid?: number; + gid?: number; + }>, ): VirtualPlatformIO { const bootSessionDir = mkdtempSync(join(tmpdir(), "wasm-posix-session-")); sessionDir = bootSessionDir; @@ -558,7 +620,10 @@ function buildVirtualPlatformIO( shmfs.chmod("/", 0o1777); const extras: MountConfig[] = (extraMounts ?? []).map((m) => ({ mountPoint: m.mountPoint, - backend: new HostFileSystem(m.hostPath), + backend: new HostFileSystem(m.hostPath, m.mountPoint, { + uid: m.uid, + gid: m.gid, + }), readonly: m.readonly, })); const mounts = [ @@ -572,7 +637,6 @@ function buildVirtualPlatformIO( ? rootMount.backend : null; if (rootfsMemfs) { - installDefaultCaBundle(rootfsMemfs); ensureMountParentDirectories(rootfsMemfs, extras.map((m) => m.mountPoint)); } return new VirtualPlatformIO(mounts, new NodeTimeProvider()); @@ -622,11 +686,22 @@ async function handleInit(msg: InitMessage) { post({ type: "proc_event", kind: "spawn", pid: childPid, ppid: parentPid }); return handleFork(parentPid, childPid, parentMemory, threadFork); }, - onExec: async (pid, path, argv, envp) => { - const result = await handleExec(pid, path, argv, envp); + onExec: async (pid, path, argv, envp, callerTid) => { + const previousWorker = processes.get(pid)?.worker; + const result = await handleExec(pid, path, argv, envp, callerTid); // Notify after handleExec refreshes kernel-side Process.argv so - // process-table consumers don't refetch stale command names. - if (result === 0) post({ type: "proc_event", kind: "exec", pid }); + // process-table consumers don't refetch stale command names. A + // post-commit signal death also returns 0 because the old syscall can + // no longer return; only emit exec when a replacement was installed. + const installedWorker = processes.get(pid)?.worker; + if ( + result === 0 + && installedWorker + && installedWorker !== previousWorker + && kernelWorker.isProcessExecutionActive(pid) + ) { + post({ type: "proc_event", kind: "exec", pid }); + } return result; }, onResolveSpawn: handlePosixSpawnResolve, @@ -654,6 +729,7 @@ async function handleInit(msg: InitMessage) { // --- Spawn --- function handleSpawn(msg: SpawnMessage) { + let registeredPid: number | undefined; try { // Allocate PID internally — skip any PIDs already occupied by fork children while (processes.has(nextSpawnPid)) { @@ -677,16 +753,18 @@ function handleSpawn(msg: SpawnMessage) { kernelWorker.registerProcess(pid, memory, [channelOffset], { ptrWidth, argv: msg.argv, + env: msg.env ?? [], brkBase: layout.brkBase, mmapBase: layout.mmapBase, maxAddr: layout.maxAddr, stdio: msg.pty ? TERMINAL_STDIO : CAPTURED_STDIO, }); + registeredPid = pid; + kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); if (msg.cwd) { kernelWorker.setCwd(pid, msg.cwd); } - kernelWorker.setCredentials(pid, { uid: msg.uid, gid: msg.gid }); if (msg.maxAddr != null) { kernelWorker.setMaxAddr(pid, msg.maxAddr); @@ -746,7 +824,7 @@ function handleSpawn(msg: SpawnMessage) { // — so surface them to stderr and synthesize an exit so the host's // exitResolver fires with a non-zero status. worker.on("message", (raw: unknown) => { - const m = raw as { type: string; pid?: number; message?: string; status?: number }; + const m = raw as WorkerToHostMessage; if (m.type === "error" && m.pid === pid) { finalizeProcessWorkerError(pid, worker, m.message); } else if (m.type === "exit" && m.pid === pid) { @@ -755,13 +833,20 @@ function handleSpawn(msg: SpawnMessage) { // the kernel didn't process a SYS_exit_group first, the kernel // still has the process registered and host.spawn() would hang. void finalizeProcessWorker(pid, worker, m.status ?? 0); + } else if (m.type === "vm_interrupt_timer" && m.pid === pid) { + const process = processes.get(pid); + if (process?.worker === worker) handleVmInterruptTimer(m, pid, process); } }); installCrashSafetyNet(worker, pid); + registeredPid = undefined; respond(msg.requestId, pid); } catch (e) { + if (registeredPid !== undefined) { + kernelWorker.unregisterProcess(registeredPid); + } respondError(msg.requestId, String(e)); } } @@ -776,11 +861,14 @@ async function handleFork( ): Promise { const parentInfo = processes.get(parentPid); const parentProgram = parentInfo?.programBytes; - if (!parentProgram) throw new Error(`Unknown parent pid ${parentPid}`); + if (!parentProgram || parentInfo.memory !== parentMemory) { + throw new Error(`Unknown parent generation for pid ${parentPid}`); + } if (!parentInfo.programModule) { parentInfo.programModule = await WebAssembly.compile(parentProgram); } + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return []; const ptrWidth = parentInfo.ptrWidth; const parentBuf = new Uint8Array(parentMemory.buffer); @@ -802,6 +890,7 @@ async function handleFork( maxAddr: childLayout.maxAddr, mmapBase: childLayout.mmapBase, }); + kernelWorker.inheritProcessSharedMappings(parentPid, childPid); const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; const forkReplayContext: ForkReplayContext | undefined = threadFork @@ -829,7 +918,9 @@ async function handleFork( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const childWorker = workerAdapter.createWorker(childInitData); + const childWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(childInitData), + ); processes.set(childPid, { memory: childMemory, programBytes: parentProgram, @@ -845,16 +936,42 @@ async function handleFork( childWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(childPid, childWorker, "worker error", err)); childWorker.on("message", (raw: unknown) => { - const m = raw as { type: string; pid?: number; message?: string; status?: number }; + const m = raw as WorkerToHostMessage; if (m.type === "error" && m.pid === childPid) { finalizeProcessWorkerError(childPid, childWorker, m.message); } else if (m.type === "exit" && m.pid === childPid) { void finalizeProcessWorker(childPid, childWorker, m.status ?? 0); + } else if (m.type === "vm_interrupt_timer" && m.pid === childPid) { + const process = processes.get(childPid); + if (process?.worker === childWorker) { + handleVmInterruptTimer(m, childPid, process); + } } }); installCrashSafetyNet(childWorker, childPid); + try { + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + childPid, + childMemory, + () => { childWorker.start(); }, + () => { void childWorker.terminate(); }, + ); + if (startDisposition === "stale") { + throw new Error(`Fork child ${childPid} changed generation before Worker launch`); + } + } catch (error) { + if (processes.get(childPid)?.worker === childWorker) { + processes.delete(childPid); + threadModuleCache.delete(childPid); + ptyByPid.delete(childPid); + vmInterruptTimers.clear(childPid); + } + void childWorker.terminate(); + throw error; + } + return [childChannelOffset]; } @@ -863,87 +980,188 @@ async function handleExec( path: string, argv: string[], envp: string[], + callerTid: number, ): Promise { + const initiatingInfo = processes.get(pid); + if (!initiatingInfo) return -3; // ESRCH + if (!kernelWorker.supportsExecMetadataReplacement()) return -38; // ENOSYS + const resolved = await resolveExecutableForLaunch(path, argv); if (!resolved) return -2; // ENOENT if ("errno" in resolved) return -resolved.errno; - const { programBytes, argv: launchArgv } = resolved; - + const { programBytes, programModule, argv: launchArgv } = resolved; const newPtrWidth = detectPtrWidth(programBytes); - const setupResult = kernelWorker.kernelExecSetup(pid); - if (setupResult < 0) return setupResult; + const metadataResult = kernelWorker.validateExecMetadata( + launchArgv, + envp, + initiatingInfo.ptrWidth, + ); + if (metadataResult < 0) return metadataResult; + let prepared: ReturnType; + try { + prepared = createFreshProcessMemory(pid, programBytes, newPtrWidth); + } catch { + return -12; // ENOMEM before the exec commit point + } - kernelWorker.prepareProcessForExec(pid); + // Resolution/compilation yielded to the event loop. The numeric pid may + // now name a replacement generation; a stale continuation must not commit + // exec state against it. + if (processes.get(pid) !== initiatingInfo + || kernelWorker.isExecHandoffActive(pid) + || !kernelWorker.isProcessExecutionActive(pid)) return -3; // ESRCH + const prepareResult = kernelWorker.kernelExecPrepare(pid, callerTid); + if (prepareResult < 0) return prepareResult; + const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(pid); + if (addressSpaceResult < 0) return addressSpaceResult; + let replacementWorker: ReturnType | undefined; + try { + const setupResult = kernelWorker.kernelExecSetup(pid, callerTid); + if (setupResult < 0) return setupResult; + vmInterruptTimers.clear(pid); + + // From this point onward the old image cannot resume. Invalidate its + // channels and async continuations immediately, before any other + // post-commit operation can fail or yield. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + kernelWorker.prepareProcessForExec(pid); - const oldInfo = processes.get(pid); - if (oldInfo?.worker) { - intentionallyTerminated.add(oldInfo.worker as object); - await oldInfo.worker.terminate().catch(() => {}); - } + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(pid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - const { - memory: newMemory, - layout: newLayout, - threadAllocator: newThreadAllocator, - } = createFreshProcessMemory(pid, programBytes, newPtrWidth); - const newChannelOffset = newLayout.channelOffset; + await terminateThreadWorkers(pid); + if (initiatingInfo.worker) { + await initiatingInfo.worker.terminate().catch(() => {}); + } + if (kernelWorker.finalizeExecHandoffTermination(pid) > 0) return 0; - kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { - skipKernelCreate: true, - ptrWidth: newPtrWidth, - brkBase: newLayout.brkBase, - mmapBase: newLayout.mmapBase, - maxAddr: newLayout.maxAddr, - // Refresh kernel-side Process.argv so /proc//cmdline reflects - // the post-exec image, not the parent's argv. Mirrors the browser - // handleExec fix. - argv: launchArgv, - }); + const { + memory: newMemory, + layout: newLayout, + threadAllocator: newThreadAllocator, + } = prepared; + const newChannelOffset = newLayout.channelOffset; - // Clear thread module cache — new program binary is different - threadModuleCache.delete(pid); + const initData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid, + ppid: 0, + programBytes, + programModule, + memory: newMemory, + channelOffset: newChannelOffset, + argv: launchArgv, + env: envp, + ptrWidth: newPtrWidth, + kernelAbiVersion: kernelWorker.getKernelAbiVersion(), + }; - const initData: CentralizedWorkerInitMessage = { - type: "centralized_init", - pid, - ppid: 0, - programBytes, - memory: newMemory, - channelOffset: newChannelOffset, - argv: launchArgv, - env: envp, - ptrWidth: newPtrWidth, - kernelAbiVersion: kernelWorker.getKernelAbiVersion(), - }; + kernelWorker.registerProcess(pid, newMemory, [newChannelOffset], { + skipKernelCreate: true, + ptrWidth: newPtrWidth, + metadataPtrWidth: initiatingInfo.ptrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + // Refresh kernel-side Process.argv and environment so procfs and + // kernel APIs reflect the replacement image. + argv: launchArgv, + env: envp, + }); + replacementWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(initData), + ); - const newWorker = workerAdapter.createWorker(initData); - processes.set(pid, { - memory: newMemory, - programBytes, - worker: newWorker, - channelOffset: newChannelOffset, - ptrWidth: newPtrWidth, - layout: newLayout, - threadAllocator: newThreadAllocator, - }); + // Clear thread module cache — new program binary is different + threadModuleCache.delete(pid); - newWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(pid, newWorker, "exec worker error", err)); + processes.set(pid, { + memory: newMemory, + programBytes, + programModule, + worker: replacementWorker, + channelOffset: newChannelOffset, + ptrWidth: newPtrWidth, + layout: newLayout, + threadAllocator: newThreadAllocator, + }); - // Forward worker-main top-level errors (instantiation failures, - // uncaught wasm traps) so the host learns the process died — same - // wiring as handleSpawn. - newWorker.on("message", (raw: unknown) => { - const m = raw as { type: string; pid?: number; message?: string; status?: number }; - if (m.type === "error" && m.pid === pid) { - finalizeProcessWorkerError(pid, newWorker, m.message); - } else if (m.type === "exit" && m.pid === pid) { - void finalizeProcessWorker(pid, newWorker, m.status ?? 0); - } - }); + replacementWorker.on("error", (err: Error) => + finalizeUnexpectedWorkerError(pid, replacementWorker!, "exec worker error", err)); - installCrashSafetyNet(newWorker, pid); + // Forward worker-main top-level errors (instantiation failures, + // uncaught wasm traps) so the host learns the process died — same + // wiring as handleSpawn. + replacementWorker.on("message", (raw: unknown) => { + const m = raw as WorkerToHostMessage; + if (m.type === "error" && m.pid === pid) { + finalizeProcessWorkerError(pid, replacementWorker!, m.message); + } else if (m.type === "exit" && m.pid === pid) { + void finalizeProcessWorker(pid, replacementWorker!, m.status ?? 0); + } else if (m.type === "vm_interrupt_timer" && m.pid === pid) { + const process = processes.get(pid); + if (process && process.worker === replacementWorker) { + handleVmInterruptTimer(m, pid, process); + } + } + }); - return 0; + installCrashSafetyNet(replacementWorker, pid); + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + pid, + newMemory, + () => { (replacementWorker as DeferredWorkerHandle).start(); }, + () => { void replacementWorker?.terminate(); }, + ); + if (startDisposition === "stale") { + throw new Error(`Exec pid ${pid} changed generation before Worker launch`); + } + if (startDisposition === "dead") { + kernelWorker.finishProcessExecHandoff(pid); + kernelWorker.finalizeExecHandoffTermination(pid); + return 0; + } + kernelWorker.finishProcessExecHandoff(pid); + return 0; + } catch (err) { + // A kernel trap can leave the commit point uncertain. We cannot safely + // return to the caller, so invalidate the old generation before yielding + // and report a truthful signal death. + if (initiatingInfo.worker) { + intentionallyTerminated.add(initiatingInfo.worker as object); + } + try { + kernelWorker.prepareProcessForExec(pid); + } catch { + // Continue with best-effort process death below. + } + if (replacementWorker && processes.get(pid)?.worker !== replacementWorker) { + await terminateTrackedWorker(replacementWorker); + } + + const message = err instanceof Error ? err.message : String(err); + try { + reportHostDiagnostic({ + pid, + status: signalExitStatus(SIGSEGV), + source: "exec post-commit transition", + message: `[exec] post-commit transition failed: ${message}`, + }); + } catch { + // A closed host port must not prevent kernel-side reap. + } + try { kernelWorker.notifyHostProcessCrashed(pid, SIGSEGV); } catch { /* best-effort */ } + try { + handleExit(pid, signalExitStatus(SIGSEGV)); + } catch { + try { kernelWorker.deactivateProcess(pid); } catch { /* best-effort */ } + } + return 0; + } } /** @@ -965,7 +1183,9 @@ async function handleExec( /** * Pre-flight resolver for SYS_SPAWN. Side-effect-free: looks up program * bytes for `path` (via the same execPrograms map + main-thread fallback - * `resolveExec` already uses for execve). Returns null on ENOENT. + * `resolveExec` already uses for execve), follows shebangs, and compiles the + * final Wasm module. Returns null on ENOENT and `{ errno }` when the located + * target cannot be launched. * * `handleSpawn` in `host/src/kernel-worker.ts` calls this BEFORE * `kernel_spawn_process` so that file_actions (which the kernel runs @@ -980,20 +1200,24 @@ async function handlePosixSpawnResolve( } /** - * Launch a worker for a SYS_SPAWN child whose program bytes have already - * been resolved by `handlePosixSpawnResolve`. The kernel has built the - * child Process descriptor + applied file actions by the time we get - * here, so this just allocates a Memory, registers the process, and - * spawns the worker. + * Launch a worker for a SYS_SPAWN child whose program has already been + * resolved and compiled by `handlePosixSpawnResolve`. The kernel has built + * the child Process descriptor + applied file actions by the time we get + * here, so this just allocates a Memory, registers the process, and spawns + * the worker. */ async function handlePosixSpawn( + parentPid: number, childPid: number, - programBytes: ArrayBuffer, - argv: string[], + program: ResolvedSpawnProgram, envp: string[], ): Promise { - post({ type: "proc_event", kind: "spawn", pid: childPid }); + // Preserve a child that became a zombie before launch, but do not resurrect + // it by registering a new execution generation. + if (!kernelWorker.shouldLaunchPendingChild(childPid)) return 0; + post({ type: "proc_event", kind: "spawn", pid: childPid, ppid: parentPid }); + const { programBytes, programModule, argv } = program; const ptrWidth = detectPtrWidth(programBytes); const { memory, @@ -1015,8 +1239,9 @@ async function handlePosixSpawn( const initData: CentralizedWorkerInitMessage = { type: "centralized_init", pid: childPid, - ppid: 0, + ppid: parentPid, programBytes, + programModule, memory, channelOffset, argv, @@ -1025,10 +1250,13 @@ async function handlePosixSpawn( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const newWorker = workerAdapter.createWorker(initData); + const newWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(initData), + ); processes.set(childPid, { memory, programBytes, + programModule, worker: newWorker, channelOffset, ptrWidth, @@ -1039,16 +1267,42 @@ async function handlePosixSpawn( newWorker.on("error", (err: Error) => finalizeUnexpectedWorkerError(childPid, newWorker, "spawn worker error", err)); newWorker.on("message", (raw: unknown) => { - const m = raw as { type: string; pid?: number; message?: string; status?: number }; + const m = raw as WorkerToHostMessage; if (m.type === "error" && m.pid === childPid) { finalizeProcessWorkerError(childPid, newWorker, m.message); } else if (m.type === "exit" && m.pid === childPid) { void finalizeProcessWorker(childPid, newWorker, m.status ?? 0); + } else if (m.type === "vm_interrupt_timer" && m.pid === childPid) { + const process = processes.get(childPid); + if (process?.worker === newWorker) { + handleVmInterruptTimer(m, childPid, process); + } } }); installCrashSafetyNet(newWorker, childPid); + try { + const startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + childPid, + memory, + () => { newWorker.start(); }, + () => { void newWorker.terminate(); }, + ); + if (startDisposition === "stale") { + throw new Error(`Spawn child ${childPid} changed generation before Worker launch`); + } + } catch (error) { + if (processes.get(childPid)?.worker === newWorker) { + processes.delete(childPid); + threadModuleCache.delete(childPid); + ptyByPid.delete(childPid); + vmInterruptTimers.clear(childPid); + } + void newWorker.terminate(); + throw error; + } + return 0; } @@ -1067,28 +1321,48 @@ async function handleClone( // Auto-compile thread module if not already cached per-PID let threadModule = threadModuleCache.get(pid); + let cacheCompiledModule = false; if (!threadModule) { const patched = patchWasmForThread(processInfo.programBytes); threadModule = await WebAssembly.compile(patched); - threadModuleCache.set(pid, threadModule); + cacheCompiledModule = true; } + // Compilation yields. A sibling pthread may have committed exec while this + // clone continuation was suspended; never attach the old program/Memory to + // the replacement process that now owns the same numeric pid. + if (!isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ) || !kernelWorker.isProcessExecutionActive(pid)) { + throw new Error(`Process ${pid} changed generation during clone`); + } + if (cacheCompiledModule) threadModuleCache.set(pid, threadModule); + let alloc: ReturnType; try { alloc = processInfo.threadAllocator.allocate(memory); } catch (e) { const message = e instanceof Error ? e.message : String(e); - post({ - type: "stderr", + reportHostDiagnostic({ pid, - data: new TextEncoder().encode(`[kernel-worker] pid=${pid}: ${message}\n`), + source: "clone allocation", + message: `[kernel-worker] pid=${pid}: ${message}`, }); throw e; } // Register fnPtr/argPtr so that handleFork can route a fork() from // this thread back through its entry point (see ForkFromThreadContext // in kernel-worker.ts). - kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr); + try { + kernelWorker.addChannel(pid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + } catch (err) { + processInfo.threadAllocator.free(alloc.basePage); + throw err; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -1097,6 +1371,7 @@ async function handleClone( programBytes: processInfo.programBytes, programModule: threadModule, memory, + processChannelOffset: processInfo.channelOffset, channelOffset: alloc.channelOffset, fnPtr, argPtr, @@ -1109,7 +1384,9 @@ async function handleClone( kernelAbiVersion: kernelWorker.getKernelAbiVersion(), }; - const threadWorker = workerAdapter.createWorker(threadInitData); + const threadWorker = new DeferredWorkerHandle( + () => workerAdapter.createWorker(threadInitData), + ); if (!threadWorkers.has(pid)) threadWorkers.set(pid, []); const threadEntry: ThreadWorkerInfo = { worker: threadWorker, @@ -1119,17 +1396,23 @@ async function handleClone( }; threadWorkers.get(pid)!.push(threadEntry); + const belongsToCurrentProcessImage = () => + isCurrentProcessGeneration( + processes, + pid, + processInfo, + memory, + kernelWorker.isExecHandoffActive(pid), + ); let reclaimed = false; const reclaimThread = () => { if (reclaimed) return; reclaimed = true; processInfo.threadAllocator.free(alloc.basePage); - threadExits.release(pid, alloc.channelOffset); - const threads = threadWorkers.get(pid); - if (threads) { - const idx = threads.indexOf(threadEntry); - if (idx >= 0) threads.splice(idx, 1); + if (belongsToCurrentProcessImage()) { + threadExits.release(pid, alloc.channelOffset); } + removeThreadWorkerRegistryEntry(threadWorkers, pid, threadEntry); }; const terminateThreadEntry = (): Promise => { if (!threadEntry.termination) { @@ -1139,10 +1422,23 @@ async function handleClone( }; threadExits.register(pid, alloc.channelOffset, terminateThreadEntry); + const isCurrentThreadGeneration = () => + !intentionallyTerminated.has(threadWorker as object) + && belongsToCurrentProcessImage(); const failThread = (reason: string) => { - const text = `[kernel-worker] pid=${pid} tid=${tid}: ${reason}\n`; - post({ type: "stderr", pid, data: new TextEncoder().encode(text) }); + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } const disposition = threadWorkerFailureDisposition(reason); + reportHostDiagnostic({ + pid, + status: disposition.kind === "guest-fatal-trap" + ? disposition.exitStatus + : undefined, + source: "thread worker failure", + message: `[kernel-worker] pid=${pid} tid=${tid}: ${reason}`, + }); kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); void terminateThreadEntry(); if (disposition.kind === "guest-fatal-trap") { @@ -1153,13 +1449,47 @@ async function handleClone( threadWorker.on("message", (msg: unknown) => { const m = msg as WorkerToHostMessage; if (m.type === "thread_exit") { + if (!isCurrentThreadGeneration()) { + void terminateThreadEntry(); + return; + } void terminateThreadEntry(); } else if (m.type === "error") { failThread(m.message); + } else if (m.type === "vm_interrupt_timer") { + if (isCurrentThreadGeneration() && m.pid === pid) { + handleVmInterruptTimer(m, pid, processInfo); + } } }); threadWorker.on("error", (err: Error) => failThread(`worker error: ${err.message ?? err}`)); + let startDisposition: ReturnType< + CentralizedKernelWorker["startProcessWorkerWhenRunnable"] + >; + try { + startDisposition = kernelWorker.startProcessWorkerWhenRunnable( + pid, + memory, + () => { threadWorker.start(); }, + () => { void threadWorker.terminate(); }, + () => { + kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); + const failedClone = kernelWorker.failDeferredCloneLaunch(pid, tid, 12); + void terminateThreadEntry(); + return failedClone; + }, + ); + } catch (error) { + kernelWorker.finalizeThreadExit(pid, tid, alloc.channelOffset); + void terminateThreadEntry(); + throw error; + } + if (startDisposition === "stale") { + void terminateThreadEntry(); + throw new Error(`Process ${pid} changed generation before thread Worker launch`); + } + return tid; } @@ -1168,18 +1498,25 @@ function handleThreadExit(pid: number, channelOffset: number): boolean { } function handleExit(pid: number, exitStatus: number): void { - void finishProcessExit(pid, exitStatus); + void finishProcessExit(pid, exitStatus, processes.get(pid)?.worker); } -async function finishProcessExit(pid: number, exitStatus: number): Promise { - const existingTeardown = processTeardowns.get(pid); +async function finishProcessExit( + pid: number, + exitStatus: number, + expectedWorker = processes.get(pid)?.worker, +): Promise { + if (!expectedWorker) return; + const info = processes.get(pid); + if (!info || info.worker !== expectedWorker) return; + vmInterruptTimers.clear(pid); + + const existingTeardown = processTeardowns.get(expectedWorker); if (existingTeardown) { reportProcessExit(pid, exitStatus); return; } - const info = processes.get(pid); - const teardown = (async () => { // Keep the pid registered until the process worker is gone. musl's // _Exit() loops on SYS_exit after SYS_exit_group returns; while worker @@ -1187,7 +1524,11 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise // completions, otherwise the worker can park in Atomics.wait with no // registered listener left to wake it. await terminateThreadWorkers(pid); - if (info?.worker) await terminateTrackedWorker(info.worker); + await terminateTrackedWorker(expectedWorker); + + // Exec may have installed a replacement while old worker termination was + // settling. Never apply pid-wide cleanup to a different generation. + if (processes.get(pid)?.worker !== expectedWorker) return; // Deactivate process (zombie until reaped or destroy) after worker // termination so no further guest syscalls can arrive on its channel. @@ -1197,7 +1538,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise threadModuleCache.delete(pid); ptyByPid.delete(pid); })(); - processTeardowns.set(pid, teardown); + processTeardowns.set(expectedWorker, teardown); // The process is already a kernel-side zombie at this point. Report the // exit before worker-thread teardown so a slow termination cannot make @@ -1208,7 +1549,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise try { await teardown; } finally { - processTeardowns.delete(pid); + processTeardowns.delete(expectedWorker); } } @@ -1216,6 +1557,7 @@ async function finishProcessExit(pid: number, exitStatus: number): Promise async function handleTerminate(msg: TerminateProcessMessage) { const pid = msg.pid; + vmInterruptTimers.clear(pid); // Terminate thread workers const threads = threadWorkers.get(pid); @@ -1284,6 +1626,7 @@ async function handleDestroy(msg: { requestId: number }) { const processEntries = [...processes.entries()]; for (const [pid, info] of processEntries) { + vmInterruptTimers.clear(pid); await terminateThreadWorkers(pid); await terminateTrackedWorker(info.worker); try { kernelWorker.unregisterProcess(pid); } catch {} @@ -1299,8 +1642,10 @@ async function handleDestroy(msg: { requestId: number }) { } } processes.clear(); + vmInterruptTimers.clearAll(); processTeardowns.clear(); reportedExits.clear(); + reportedNonzeroProcessExits.clear(); threadModuleCache.clear(); threadWorkers.clear(); ptyByPid.clear(); @@ -1445,5 +1790,13 @@ port.on("message", (msg: MainToKernelMessage) => { case "kms_attach_stats": kernelWorker.attachKmsStats(msg.crtcId, msg.stats); break; + default: { + const exhaustive: never = msg; + void exhaustive; + reportWorkerProtocolError( + `unknown main-thread message type: ${String((msg as { type?: unknown }).type)}`, + ); + break; + } } }); diff --git a/host/src/pathconf.ts b/host/src/pathconf.ts new file mode 100644 index 0000000000..8401568e52 --- /dev/null +++ b/host/src/pathconf.ts @@ -0,0 +1,84 @@ +import { PATHCONF_NAMES } from "./generated/abi"; +import type { PathconfValue, StatResult } from "./types"; + +export interface PathconfProfile { + supportsSymlinks: boolean; + timestampResolutionNs: number | null; +} + +function invalidAssociation(name: number): never { + const error = new Error( + `EINVAL: pathconf name ${name} is not associated with this object`, + ) as Error & { code: string }; + error.code = "EINVAL"; + throw error; +} + +/** + * Answer filesystem-backed pathconf names after the owning backend has + * validated the path or live handle. Kernel-owned pipes, sockets, and PTYs + * are handled in Rust instead. + */ +export function filesystemPathconf( + stat: StatResult, + name: number, + profile: PathconfProfile, +): PathconfValue { + switch (name) { + case PATHCONF_NAMES.LINK_MAX: + return null; // no backend currently enforces an authoritative maximum + case PATHCONF_NAMES.NAME_MAX: + return 255; // enforced in bytes by the common namespace resolver + case PATHCONF_NAMES.PATH_MAX: + return 4096; // enforced in bytes by the common namespace resolver + case PATHCONF_NAMES.CHOWN_RESTRICTED: + // The kernel enforces chown authorization before every backend call, + // including backends without persistent ownership metadata. + return 1; + case PATHCONF_NAMES.NO_TRUNC: + return 1; // the common resolver rejects overlong byte components + case PATHCONF_NAMES.ASYNC_IO: + // musl implements AIO with guest pthreads over pread/pwrite/fsync. + return (stat.mode & 0o170000) === 0o100000 + ? 1 + : invalidAssociation(name); + case PATHCONF_NAMES.SYNC_IO: + case PATHCONF_NAMES.PRIO_IO: + case PATHCONF_NAMES.FILESIZEBITS: + case PATHCONF_NAMES.REC_INCR_XFER_SIZE: + case PATHCONF_NAMES.REC_MAX_XFER_SIZE: + case PATHCONF_NAMES.REC_MIN_XFER_SIZE: + case PATHCONF_NAMES.REC_XFER_ALIGN: + case PATHCONF_NAMES.ALLOC_SIZE_MIN: + case PATHCONF_NAMES.SYMLINK_MAX: + case PATHCONF_NAMES.FALLOC: + return null; + case PATHCONF_NAMES.POSIX2_SYMLINKS: + return profile.supportsSymlinks ? 1 : null; + case PATHCONF_NAMES.TEXTDOMAIN_MAX: + return 255; + case PATHCONF_NAMES.TIMESTAMP_RESOLUTION: + return profile.timestampResolutionNs; + case PATHCONF_NAMES.PIPE_BUF: { + const fileType = stat.mode & 0o170000; + // Named FIFO support and host atomicity are not uniform yet. Preserve + // the valid association without fabricating a numeric guarantee. For a + // directory the value applies to FIFOs created within that directory. + return fileType === 0o010000 || fileType === 0o040000 + ? null + : invalidAssociation(name); + } + case PATHCONF_NAMES.MAX_CANON: + case PATHCONF_NAMES.MAX_INPUT: + case PATHCONF_NAMES.VDISABLE: + case PATHCONF_NAMES.SOCK_MAXBUF: + return invalidAssociation(name); + default: { + const error = new Error(`EINVAL: invalid pathconf name ${name}`) as Error & { + code: string; + }; + error.code = "EINVAL"; + throw error; + } + } +} diff --git a/host/src/platform/native-metadata.ts b/host/src/platform/native-metadata.ts index b90463a3a3..ef1954ddd7 100644 --- a/host/src/platform/native-metadata.ts +++ b/host/src/platform/native-metadata.ts @@ -11,6 +11,11 @@ interface VirtualMetadata { mode?: number; uid?: number; gid?: number; + atimeMs?: number; + mtimeMs?: number; + nativeAtimeMs?: number; + nativeMtimeMs?: number; + nativeCtimeMs?: number; ctimeMs?: number; } @@ -24,8 +29,14 @@ interface VirtualMetadata { export class NativeMetadataOverlay { private readonly entries = new Map(); + constructor( + private readonly defaultUid = 0, + private readonly defaultGid = 0, + ) {} + toStatResult(s: Stats): StatResult { const metadata = this.entries.get(this.key(s)); + if (metadata !== undefined) this.reconcileNativeTimes(metadata, s); return { dev: s.dev, ino: s.ino, @@ -33,12 +44,14 @@ export class NativeMetadataOverlay { ? s.mode : (s.mode & ~MODE_CHANGE_MASK) | (metadata.mode & MODE_CHANGE_MASK), nlink: s.nlink, - uid: metadata?.uid ?? 0, - gid: metadata?.gid ?? 0, + uid: metadata?.uid ?? this.defaultUid, + gid: metadata?.gid ?? this.defaultGid, size: s.size, - atimeMs: s.atimeMs, - mtimeMs: s.mtimeMs, - ctimeMs: metadata?.ctimeMs ?? s.ctimeMs, + atimeMs: metadata?.atimeMs ?? s.atimeMs, + mtimeMs: metadata?.mtimeMs ?? s.mtimeMs, + ctimeMs: metadata?.ctimeMs === undefined + ? s.ctimeMs + : Math.max(metadata.ctimeMs, s.ctimeMs), }; } @@ -55,6 +68,27 @@ export class NativeMetadataOverlay { metadata.ctimeMs = Date.now(); } + utimens( + s: Stats, + atimeMs: number, + mtimeMs: number, + nativeAfter: Stats, + ): void { + const metadata = this.metadataFor(s); + metadata.atimeMs = atimeMs; + metadata.mtimeMs = mtimeMs; + metadata.nativeAtimeMs = nativeAfter.atimeMs; + metadata.nativeMtimeMs = nativeAfter.mtimeMs; + metadata.nativeCtimeMs = nativeAfter.ctimeMs; + metadata.ctimeMs = Math.max(metadata.ctimeMs ?? 0, nativeAfter.ctimeMs); + } + + noteNativeContentChange(s: Stats): void { + const metadata = this.entries.get(this.key(s)); + if (metadata === undefined) return; + this.clearTimeOverrides(metadata); + } + forget(s: Stats): void { this.entries.delete(this.key(s)); } @@ -76,6 +110,38 @@ export class NativeMetadataOverlay { return metadata; } + private reconcileNativeTimes(metadata: VirtualMetadata, s: Stats): void { + if (metadata.nativeCtimeMs === undefined) return; + + const nativeMetadataChanged = s.ctimeMs !== metadata.nativeCtimeMs; + if ( + nativeMetadataChanged || + (metadata.nativeAtimeMs !== undefined && s.atimeMs !== metadata.nativeAtimeMs) + ) { + delete metadata.atimeMs; + delete metadata.nativeAtimeMs; + } + if ( + nativeMetadataChanged || + (metadata.nativeMtimeMs !== undefined && s.mtimeMs !== metadata.nativeMtimeMs) + ) { + delete metadata.mtimeMs; + delete metadata.nativeMtimeMs; + } + + if (metadata.atimeMs === undefined && metadata.mtimeMs === undefined) { + delete metadata.nativeCtimeMs; + } + } + + private clearTimeOverrides(metadata: VirtualMetadata): void { + delete metadata.atimeMs; + delete metadata.mtimeMs; + delete metadata.nativeAtimeMs; + delete metadata.nativeMtimeMs; + delete metadata.nativeCtimeMs; + } + private key(s: Stats): string { return `${s.dev}:${s.ino}`; } diff --git a/host/src/platform/node.ts b/host/src/platform/node.ts index 8bf55dce21..2d259055af 100644 --- a/host/src/platform/node.ts +++ b/host/src/platform/node.ts @@ -9,10 +9,34 @@ import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import type { PlatformIO, StatResult, StatfsResult } from "../types"; +import type { PathconfValue, PlatformIO, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import { nativeStatfs, translateOpenFlags } from "../vfs/host-fs"; 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; @@ -97,6 +121,7 @@ export class NodePlatformIO implements PlatformIO { ): number { const pos = offset ?? this.fdPositions.get(handle) ?? 0; const bytesWritten = fs.writeSync(handle, buffer, 0, length, pos); + if (bytesWritten > 0) this.metadata.noteNativeContentChange(fs.fstatSync(handle)); if (offset === null) { this.fdPositions.set(handle, pos + bytesWritten); } @@ -112,21 +137,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; @@ -142,6 +167,34 @@ export class NodePlatformIO implements PlatformIO { return this.metadata.toStatResult(fs.fstatSync(handle)); } + fpathconf(handle: number, name: number): PathconfValue { + // Validate the live descriptor rather than re-resolving its original + // pathname. This keeps fpathconf valid after rename or unlink. + const stat = this.fstat(handle); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + + fileIdentity(_path: string, dev: bigint, ino: bigint): string | null { + // Native inode numbers are filesystem-scoped and therefore preserve + // aliases reached through separate hard-link paths. An absent inode is + // not a stable object identity; callers must reject rather than fall back + // to a pathname that can be renamed or reused. + if (ino <= 0n || dev < 0n) return null; + return `node:${dev}:${ino}`; + } + + fileHandleIdentity(_handle: number, dev: bigint, ino: bigint): string | null { + if (ino <= 0n || dev < 0n) return null; + return `node:${dev}:${ino}`; + } + stat(path: string): StatResult { return this.metadata.toStatResult(fs.statSync(this.rewritePath(path))); } @@ -154,6 +207,19 @@ export class NodePlatformIO implements PlatformIO { return nativeStatfs(this.rewritePath(path)); } + pathconf(path: string, name: number): PathconfValue { + const nativePath = this.rewritePath(path); + const stat = this.metadata.toStatResult(fs.statSync(nativePath)); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + mkdir(path: string, mode: number): void { const nativePath = this.rewritePath(path); fs.mkdirSync(nativePath, { mode }); @@ -204,14 +270,33 @@ export class NodePlatformIO implements PlatformIO { this.metadata.chown(fs.statSync(this.rewritePath(path)), uid, gid); } + lchown(path: string, uid: number, gid: number): void { + this.metadata.chown(fs.lstatSync(this.rewritePath(path)), uid, gid); + } + access(path: string, mode: number): void { this.metadata.access(fs.statSync(this.rewritePath(path)), mode); } utimensat(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void { - const atime = atimeSec + atimeNsec / 1e9; - const mtime = mtimeSec + mtimeNsec / 1e9; - fs.utimesSync(this.rewritePath(path), atime, mtime); + const nativePath = this.rewritePath(path); + if (atimeNsec === UTIME_OMIT && mtimeNsec === UTIME_OMIT) return; + + const stat = fs.statSync(nativePath); + const current = this.metadata.toStatResult(stat); + const nowMs = Date.now(); + const atimeMs = atimeNsec === UTIME_OMIT + ? current.atimeMs + : atimeNsec === UTIME_NOW + ? nowMs + : atimeSec * 1000 + Math.floor(atimeNsec / 1_000_000); + const mtimeMs = mtimeNsec === UTIME_OMIT + ? current.mtimeMs + : mtimeNsec === UTIME_NOW + ? nowMs + : mtimeSec * 1000 + Math.floor(mtimeNsec / 1_000_000); + fs.utimesSync(nativePath, atimeMs / 1000, mtimeMs / 1000); + this.metadata.utimens(stat, atimeMs, mtimeMs, fs.statSync(nativePath)); } opendir(path: string): number { @@ -249,6 +334,7 @@ export class NodePlatformIO implements PlatformIO { ftruncate(handle: number, length: number): void { fs.ftruncateSync(handle, length); + this.metadata.noteNativeContentChange(fs.fstatSync(handle)); } fsync(handle: number): void { @@ -273,8 +359,8 @@ export class NodePlatformIO implements PlatformIO { const elapsed = ns - this._startNs; return { sec: Number(elapsed / 1000000000n), nsec: Number(elapsed % 1000000000n) }; } - if (clockId === 1) { - // CLOCK_MONOTONIC + if (clockId === 1 || clockId === 7) { + // CLOCK_MONOTONIC / CLOCK_BOOTTIME return { sec: Number(ns / 1000000000n), nsec: Number(ns % 1000000000n) }; } // CLOCK_REALTIME — use hrtime + epoch offset for nanosecond resolution diff --git a/host/src/thread-exit-coordinator.ts b/host/src/thread-exit-coordinator.ts index c113f5d2d4..011bc63378 100644 --- a/host/src/thread-exit-coordinator.ts +++ b/host/src/thread-exit-coordinator.ts @@ -5,8 +5,9 @@ export type ThreadTerminator = () => Promise; * * Starting a Worker is not atomic with installing all host bookkeeping: a very * short-lived pthread can reach SYS_exit before its terminator is registered. - * The kernel must still abandon that syscall channel so guest code cannot run - * past SYS_exit and race pthread_join() stack reclamation. + * The kernel must still complete that syscall channel before terminating the + * Worker so guest code cannot run past SYS_exit or race pthread_join() stack + * reclamation. */ export class ThreadExitCoordinator { private terminators = new Map(); diff --git a/host/src/thread-worker-disposition.ts b/host/src/thread-worker-disposition.ts index 187ed26a6f..777a006f37 100644 --- a/host/src/thread-worker-disposition.ts +++ b/host/src/thread-worker-disposition.ts @@ -13,6 +13,21 @@ export type ThreadWorkerFailureDisposition = kind: "host-thread-failure"; }; +/** Remove one worker generation and retire an empty per-process registry slot. */ +export function removeThreadWorkerRegistryEntry( + registry: Map, + pid: number, + entry: T, +): boolean { + const entries = registry.get(pid); + if (!entries) return false; + const index = entries.indexOf(entry); + if (index < 0) return false; + entries.splice(index, 1); + if (entries.length === 0) registry.delete(pid); + return true; +} + function signalFromExitStatus(exitStatus: number): number | null { return exitStatus >= 128 ? (exitStatus - 128) & 0x7f : null; } diff --git a/host/src/types.ts b/host/src/types.ts index 1b0e6e3544..cffe0b385c 100644 --- a/host/src/types.ts +++ b/host/src/types.ts @@ -40,6 +40,9 @@ export interface StatfsResult { flags: number; } +/** `null` represents a successful indeterminate/unsupported-option result. */ +export type PathconfValue = number | null; + export interface PlatformIO { open(path: string, flags: number, mode: number): number; close(handle: number): number; @@ -57,11 +60,33 @@ export interface PlatformIO { ): number; seek(handle: number, offset: number, whence: number): number; fstat(handle: number): StatResult; + fpathconf(handle: number, name: number): PathconfValue; + + /** + * Qualify a filesystem-reported inode within this PlatformIO instance. + * + * The path is used only to select the owning mount/backend; callers may + * pass the remembered path of an unlinked or renamed open file. Equal + * identities must name the same underlying file object, including through + * hard links. Return null when the backend cannot promise stable object + * identity (for example, a backend that reports no inode number). + */ + fileIdentity?(path: string, dev: bigint, ino: bigint): string | null; + + /** + * Qualify an inode through an already-open file handle. + * + * Unlike `fileIdentity`, this must not resolve the remembered pathname: an + * open file remains a valid mmap backing after that name is unlinked or + * renamed. Return null when the backend cannot promise stable identity. + */ + fileHandleIdentity?(handle: number, dev: bigint, ino: bigint): string | null; // Path-based operations stat(path: string): StatResult; lstat(path: string): StatResult; statfs(path: string): StatfsResult; + pathconf(path: string, name: number): PathconfValue; mkdir(path: string, mode: number): void; rmdir(path: string): void; unlink(path: string): void; @@ -71,6 +96,7 @@ export interface PlatformIO { readlink(path: string): string; chmod(path: string, mode: number): void; chown(path: string, uid: number, gid: number): void; + lchown(path: string, uid: number, gid: number): void; access(path: string, mode: number): void; utimensat(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void; @@ -107,8 +133,12 @@ export interface TcpConnectionPeer { send(data: Uint8Array, flags: number): number; recv(maxLen: number, flags: number): Uint8Array; poll?(events: number): number; + /** Disable one or both directions without resetting the connection. */ shutdown(how: number): void; + /** Orderly close: flush/FIN the write half and orphan the receive half. */ close(): void; + /** Abort immediately and make both peers observe a connection reset. */ + abort(): void; } export interface TcpListenTarget { diff --git a/host/src/vfs/device-fs.ts b/host/src/vfs/device-fs.ts index cc2c28217a..b5d10500f2 100644 --- a/host/src/vfs/device-fs.ts +++ b/host/src/vfs/device-fs.ts @@ -1,4 +1,5 @@ -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; import { DEVFS_SUPER_MAGIC, zeroCapacityStatfs } from "../statfs"; @@ -156,6 +157,14 @@ export class DeviceFileSystem implements FileSystemBackend { }; } + fpathconf(handle: number, name: number): PathconfValue { + const stat = this.fstat(handle); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + ftruncate(_handle: number, _length: number): void {} fsync(_handle: number): void {} fchmod(_handle: number, _mode: number): void {} @@ -198,6 +207,14 @@ export class DeviceFileSystem implements FileSystemBackend { return stats; } + pathconf(path: string, name: number): PathconfValue { + const stat = this.stat(path); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + mkdir(_path: string, _mode: number): void { throw new Error("EACCES"); } @@ -229,6 +246,8 @@ export class DeviceFileSystem implements FileSystemBackend { chmod(_path: string, _mode: number): void {} chown(_path: string, _uid: number, _gid: number): void {} + lchown(_path: string, _uid: number, _gid: number): void {} + access(path: string, _mode: number): void { this.stat(path); // throws ENOENT if not found } diff --git a/host/src/vfs/host-fs.ts b/host/src/vfs/host-fs.ts index 11d57d883f..a75b682c03 100644 --- a/host/src/vfs/host-fs.ts +++ b/host/src/vfs/host-fs.ts @@ -7,11 +7,35 @@ import * as fs from "node:fs"; import * as nodePath from "node:path"; -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; import { NativeMetadataOverlay } from "../platform/native-metadata"; +import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; 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. @@ -42,11 +66,11 @@ export function translateOpenFlags(linuxFlags: number): number { if (linuxFlags & L_O_TRUNC) native |= fs.constants.O_TRUNC; if (linuxFlags & L_O_APPEND) native |= fs.constants.O_APPEND; if (linuxFlags & L_O_NONBLOCK) native |= fs.constants.O_NONBLOCK; - if ((linuxFlags & L_O_DIRECTORY) && fs.constants.O_DIRECTORY) + if (linuxFlags & L_O_DIRECTORY && fs.constants.O_DIRECTORY) native |= fs.constants.O_DIRECTORY; - if ((linuxFlags & L_O_NOFOLLOW) && fs.constants.O_NOFOLLOW) + if (linuxFlags & L_O_NOFOLLOW && fs.constants.O_NOFOLLOW) native |= fs.constants.O_NOFOLLOW; - if ((linuxFlags & L_O_NOCTTY) && fs.constants.O_NOCTTY) + if (linuxFlags & L_O_NOCTTY && fs.constants.O_NOCTTY) native |= fs.constants.O_NOCTTY; // O_LARGEFILE and O_CLOEXEC have no Node.js equivalent; ignored. @@ -83,31 +107,158 @@ export function nativeStatfs(path: string): StatfsResult { export class HostFileSystem implements FileSystemBackend { private rootPath: string; + private guestMountPoint: string; private fdPositions = new Map(); private dirHandles = new Map(); private nextDirHandle = 1; - private metadata = new NativeMetadataOverlay(); - - constructor(rootPath: string) { - this.rootPath = nodePath.resolve(rootPath); + private metadata: NativeMetadataOverlay; + + constructor( + rootPath: string, + guestMountPoint = "/", + options: { uid?: number; gid?: number } = {}, + ) { + const resolvedRoot = nodePath.resolve(rootPath); + this.rootPath = fs.existsSync(resolvedRoot) + ? fs.realpathSync(resolvedRoot) + : resolvedRoot; + this.guestMountPoint = this.normalizeGuestMountPoint(guestMountPoint); + this.metadata = new NativeMetadataOverlay( + options.uid ?? 0, + options.gid ?? 0, + ); } /** - * Resolve a mount-relative path to an absolute host path, - * ensuring it stays within `rootPath`. + * Resolve a mount-relative guest path to an absolute host path, ensuring it + * stays within `rootPath`. + * + * This intentionally resolves components one at a time instead of using + * `path.resolve()`. POSIX pathname resolution must look up an intermediate + * component before a following `..` can step back out of it: + * `existing/missing/../file` fails with ENOENT because `missing` is looked + * up as a directory first. Lexical normalization would incorrectly collapse + * that to `existing/file`. + * + * Resolved prefixes are deliberately not cached. A host-backed tree can be + * changed externally or through another mount of the same directory; using + * a stale prefix after a directory-to-symlink replacement would bypass the + * component checks below. + * + * Native symlink targets are stored as guest strings. When following a + * symlink whose target is absolute and still inside this mount, translate it + * back to a mount-relative path before continuing. This preserves readlink(2) + * output while allowing stat/open/chmod to follow absolute in-guest links. */ - private safePath(relative: string): string { - const resolved = nodePath.resolve( - this.rootPath, - relative.replace(/^\//, ""), - ); + private safePath(relative: string, followFinal = true): string { + const hadTrailingSlash = relative.length > 1 && /\/+$/.test(relative); + let current = this.rootPath; + let pending = this.pathParts(relative); + let symlinkDepth = 0; + + while (pending.length > 0) { + const part = pending.shift()!; + if (part === ".") continue; + if (part === "..") { + if (current === this.rootPath) { + throw new Error("EACCES: path traversal blocked"); + } + current = nodePath.dirname(current); + continue; + } + + const candidate = nodePath.join(current, part); + const isFinal = pending.length === 0; + const shouldFollow = !isFinal || followFinal; + + let lst: fs.Stats | null = null; + try { + lst = fs.lstatSync(candidate); + } catch (err: any) { + if (isFinal && err?.code === "ENOENT") { + current = candidate; + break; + } + throw err; + } + + if (shouldFollow && lst.isSymbolicLink()) { + if (++symlinkDepth > 40) + throw new Error("ELOOP: too many symbolic links"); + const target = fs.readlinkSync(candidate, "utf8"); + if (target.startsWith("/")) { + const mountRelative = this.guestAbsoluteToMountRelative(target); + if (mountRelative === null) { + throw new Error("EACCES: absolute symlink target escapes mount"); + } + current = this.rootPath; + pending = [...this.pathParts(mountRelative), ...pending]; + } else { + pending = [...this.pathParts(target), ...pending]; + } + continue; + } + + if (!isFinal && !lst.isDirectory()) { + throw new Error("ENOTDIR: not a directory"); + } + + if (!isFinal) { + current = fs.realpathSync(candidate); + this.assertWithinRoot(current); + } else { + current = candidate; + } + } + + if ( + hadTrailingSlash && + current !== this.rootPath && + !current.endsWith(nodePath.sep) + ) { + // Keep a final separator for native fs calls. POSIX requires a + // trailing slash to resolve the preceding component as a directory; the + // native call then returns ENOTDIR for regular files while still + // permitting operations such as mkdir("new-dir/"). + current += nodePath.sep; + } + this.assertWithinRoot(current); + return current; + } + + private normalizeGuestMountPoint(mountPoint: string): string { + if (!mountPoint.startsWith("/")) mountPoint = `/${mountPoint}`; + return mountPoint !== "/" && mountPoint.endsWith("/") + ? mountPoint.slice(0, -1) + : mountPoint; + } + + private pathParts(path: string): string[] { + return path + .replace(/^\/+/, "") + .split("/") + .filter((part) => part.length > 0 && part !== "."); + } + + private guestAbsoluteToMountRelative(path: string): string | null { + if (this.guestMountPoint === "/") return path; + if (path === this.guestMountPoint) return "/"; + if (path.startsWith(`${this.guestMountPoint}/`)) { + return path.slice(this.guestMountPoint.length) || "/"; + } + return null; + } + + private assertWithinRoot(path: string): void { + const rel = nodePath.relative(this.rootPath, path); + if (rel === "") return; if ( - resolved !== this.rootPath && - !resolved.startsWith(this.rootPath + nodePath.sep) + rel === ".." || + rel.startsWith(`..${nodePath.sep}`) || + nodePath.isAbsolute(rel) ) { throw new Error("EACCES: path traversal blocked"); } - return resolved; } private toStatResult(s: fs.Stats): StatResult { @@ -124,7 +275,10 @@ export class HostFileSystem implements FileSystemBackend { // ── File handle operations ─────────────────────────────────── open(path: string, flags: number, mode: number): number { - const nativePath = this.safePath(path); + const noFollowFinal = + (flags & 0o400000) !== 0 || + ((flags & 0o100) !== 0 && (flags & 0o200) !== 0); + const nativePath = this.safePath(path, !noFollowFinal); const created = (flags & 0o100) !== 0 && !fs.existsSync(nativePath); const fd = fs.openSync(nativePath, translateOpenFlags(flags), mode); if (created) this.metadata.chmod(fs.fstatSync(fd), mode); @@ -160,6 +314,8 @@ export class HostFileSystem implements FileSystemBackend { ): number { const pos = offset ?? this.fdPositions.get(handle) ?? 0; const bytesWritten = fs.writeSync(handle, buffer, 0, length, pos); + if (bytesWritten > 0) + this.metadata.noteNativeContentChange(fs.fstatSync(handle)); if (offset === null) { this.fdPositions.set(handle, pos + bytesWritten); } @@ -170,16 +326,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; @@ -189,8 +345,24 @@ export class HostFileSystem implements FileSystemBackend { return this.toStatResult(fs.fstatSync(handle)); } + fpathconf(handle: number, name: number): PathconfValue { + // Validate the live descriptor. The remaining values are Kandelo + // namespace/backend capabilities and do not depend on a remembered path, + // so this remains valid after the opened file is renamed or unlinked. + const stat = this.fstat(handle); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + ftruncate(handle: number, length: number): void { fs.ftruncateSync(handle, length); + this.metadata.noteNativeContentChange(fs.fstatSync(handle)); } fsync(handle: number): void { @@ -212,53 +384,73 @@ export class HostFileSystem implements FileSystemBackend { } lstat(path: string): StatResult { - return this.toStatResult(fs.lstatSync(this.safePath(path))); + return this.toStatResult(fs.lstatSync(this.safePath(path, false))); } statfs(path: string): StatfsResult { return nativeStatfs(this.safePath(path)); } - mkdir(path: string, mode: number): void { + pathconf(path: string, name: number): PathconfValue { const nativePath = this.safePath(path); + const stat = this.toStatResult(fs.statSync(nativePath)); + return filesystemPathconf( + stat, + name, + { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }, + ); + } + + mkdir(path: string, mode: number): void { + const nativePath = this.safePath(path, false); fs.mkdirSync(nativePath, { mode }); this.metadata.chmod(fs.statSync(nativePath), mode); } rmdir(path: string): void { - const nativePath = this.safePath(path); + const nativePath = this.safePath(path, false); const stat = fs.lstatSync(nativePath); fs.rmdirSync(nativePath); this.metadata.forget(stat); } unlink(path: string): void { - const nativePath = this.safePath(path); + const nativePath = this.safePath(path, false); const stat = fs.lstatSync(nativePath); fs.unlinkSync(nativePath); if (stat.nlink <= 1) this.metadata.forget(stat); } rename(oldPath: string, newPath: string): void { - const nativeNewPath = this.safePath(newPath); + const nativeNewPath = this.safePath(newPath, false); let replaced: fs.Stats | undefined; try { replaced = fs.lstatSync(nativeNewPath); } catch {} - fs.renameSync(this.safePath(oldPath), nativeNewPath); - if (replaced !== undefined && replaced.nlink <= 1) this.metadata.forget(replaced); + fs.renameSync(this.safePath(oldPath, false), nativeNewPath); + if (replaced !== undefined && replaced.nlink <= 1) + this.metadata.forget(replaced); } link(existingPath: string, newPath: string): void { - fs.linkSync(this.safePath(existingPath), this.safePath(newPath)); + // Resolve intermediate components ourselves, but leave the final source + // component to native link(2). POSIX permits link() either to follow a + // final symlink or to link the symlink inode; native hosts differ here. + fs.linkSync( + this.safePath(existingPath, false), + this.safePath(newPath, false), + ); } symlink(target: string, path: string): void { - fs.symlinkSync(target, this.safePath(path)); + fs.symlinkSync(target, this.safePath(path, false)); } readlink(path: string): string { - return fs.readlinkSync(this.safePath(path), "utf8"); + return fs.readlinkSync(this.safePath(path, false), "utf8"); } chmod(path: string, mode: number): void { @@ -269,14 +461,41 @@ export class HostFileSystem implements FileSystemBackend { this.metadata.chown(fs.statSync(this.safePath(path)), uid, gid); } + lchown(path: string, uid: number, gid: number): void { + this.metadata.chown(fs.lstatSync(this.safePath(path, false)), uid, gid); + } + access(path: string, mode: number): void { this.metadata.access(fs.statSync(this.safePath(path)), mode); } - utimensat(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void { - const atime = atimeSec + atimeNsec / 1e9; - const mtime = mtimeSec + mtimeNsec / 1e9; - fs.utimesSync(this.safePath(path), atime, mtime); + utimensat( + path: string, + atimeSec: number, + atimeNsec: number, + mtimeSec: number, + mtimeNsec: number, + ): void { + const nativePath = this.safePath(path); + if (atimeNsec === UTIME_OMIT && mtimeNsec === UTIME_OMIT) return; + + const stat = fs.statSync(nativePath); + const current = this.metadata.toStatResult(stat); + const nowMs = Date.now(); + const atimeMs = + atimeNsec === UTIME_OMIT + ? current.atimeMs + : atimeNsec === UTIME_NOW + ? nowMs + : atimeSec * 1000 + Math.floor(atimeNsec / 1_000_000); + const mtimeMs = + mtimeNsec === UTIME_OMIT + ? current.mtimeMs + : mtimeNsec === UTIME_NOW + ? nowMs + : mtimeSec * 1000 + Math.floor(mtimeNsec / 1_000_000); + fs.utimesSync(nativePath, atimeMs / 1000, mtimeMs / 1000); + this.metadata.utimens(stat, atimeMs, mtimeMs, fs.statSync(nativePath)); } // ── Directory iteration ───────────────────────────────────── @@ -295,12 +514,18 @@ export class HostFileSystem implements FileSystemBackend { if (!entry) return null; let dtype = 0; // DT_UNKNOWN - if (entry.isFile()) dtype = 8; // DT_REG - else if (entry.isDirectory()) dtype = 4; // DT_DIR - else if (entry.isSymbolicLink()) dtype = 10; // DT_LNK - else if (entry.isFIFO()) dtype = 1; // DT_FIFO - else if (entry.isSocket()) dtype = 12; // DT_SOCK - else if (entry.isCharacterDevice()) dtype = 2; // DT_CHR + if (entry.isFile()) + dtype = 8; // DT_REG + else if (entry.isDirectory()) + dtype = 4; // DT_DIR + else if (entry.isSymbolicLink()) + dtype = 10; // DT_LNK + else if (entry.isFIFO()) + dtype = 1; // DT_FIFO + else if (entry.isSocket()) + dtype = 12; // DT_SOCK + else if (entry.isCharacterDevice()) + dtype = 2; // DT_CHR else if (entry.isBlockDevice()) dtype = 6; // DT_BLK return { name: entry.name, type: dtype, ino: 0 }; 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/image-helpers.ts b/host/src/vfs/image-helpers.ts index aa1ba82302..606c127987 100644 --- a/host/src/vfs/image-helpers.ts +++ b/host/src/vfs/image-helpers.ts @@ -28,8 +28,27 @@ export function writeVfsBinary( mode = 0o755, ): void { const fd = fs.open(path, O_WRONLY_CREAT_TRUNC, mode); - fs.write(fd, data, 0, data.length); - fs.close(fd); + try { + let offset = 0; + while (offset < data.length) { + const remaining = data.subarray(offset); + const written = fs.write(fd, remaining, offset, remaining.length); + if ( + !Number.isInteger(written) + || written <= 0 + || written > remaining.length + ) { + const detail = written < 0 + ? `write failed with error code ${written}` + : `write made invalid progress ` + + `(${written} of ${remaining.length} remaining bytes)`; + throw new Error(`Failed to stage complete VFS file ${path}: ${detail}`); + } + offset += written; + } + } finally { + fs.close(fd); + } } /** mkdir, swallowing EEXIST. */ diff --git a/host/src/vfs/index.ts b/host/src/vfs/index.ts index bafcebcca5..a1aaaffba7 100644 --- a/host/src/vfs/index.ts +++ b/host/src/vfs/index.ts @@ -16,6 +16,10 @@ export { OpfsFileSystem } from "./opfs"; export { OpfsChannel, OpfsChannelStatus, OpfsOpcode, OPFS_CHANNEL_SIZE } from "./opfs-channel"; export { NodeTimeProvider, BrowserTimeProvider } from "./time"; export type { FileSystemBackend, TimeProvider, MountConfig, DirEntry } from "./types"; +export { PATHCONF_NAMES } from "../generated/abi"; +export { filesystemPathconf } from "../pathconf"; +export type { PathconfProfile } from "../pathconf"; +export type { PathconfValue } from "../types"; export { DEFAULT_MOUNT_SPEC, ensureMountParentDirectories, diff --git a/host/src/vfs/memory-fs.ts b/host/src/vfs/memory-fs.ts index edc470bce0..3bcef26108 100644 --- a/host/src/vfs/memory-fs.ts +++ b/host/src/vfs/memory-fs.ts @@ -1,9 +1,12 @@ import { decompress as zstdDecompress } from "fzstd"; -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import { SFFS_SUPER_MAGIC } from "../statfs"; import type { FileSystemBackend, DirEntry } from "./types"; import { SharedFS, + type NamespaceEntryIdentity, + type SharedFsIdentityState, type StatResult as SfsStatResult, } from "./sharedfs-vendor"; import type { ZipEntry } from "./zip"; @@ -11,7 +14,13 @@ import type { ZipEntry } from "./zip"; /** Serializable lazy file entry for transfer between instances. */ export interface LazyFileEntry { ino: number; + /** Inode-slot generation; omitted only by legacy serialized metadata. */ + generation?: number; + /** Inode data-mutation sequence; omitted only by legacy metadata. */ + dataSequence?: number; path: string; + /** All hard-link names for this inode; omitted by legacy metadata. */ + paths?: string[]; url: string; size: number; } @@ -37,9 +46,17 @@ export type LazyDownloadListener = (event: LazyDownloadEvent) => void; /** Per-file metadata for a file inside a lazy archive. */ export interface LazyArchiveFileEntry { ino: number; + /** Inode-slot generation; omitted only by legacy serialized metadata. */ + generation?: number; + /** Inode data-mutation sequence; omitted only by legacy metadata. */ + dataSequence?: number; size: number; isSymlink: boolean; deleted: boolean; + /** True once this inode's archive backing is no longer pending. */ + materialized?: boolean; + /** Original path inside the archive (stable across VFS rename/hard-link). */ + archivePath?: string; } /** @@ -61,9 +78,13 @@ export interface SerializedLazyArchiveEntry { entries: Array<{ vfsPath: string; ino: number; + generation?: number; + dataSequence?: number; size: number; isSymlink: boolean; deleted: boolean; + materialized?: boolean; + archivePath?: string; }>; } @@ -118,7 +139,9 @@ const COPY_CHUNK_BYTES = 1024 * 1024; const MIN_REBASE_INITIAL_BYTES = 16 * 1024 * 1024; const VFS_IMAGE_MAX_METADATA_BYTES = 64 * 1024; -function cloneMetadata(metadata: VfsImageMetadata | null): VfsImageMetadata | null { +function cloneMetadata( + metadata: VfsImageMetadata | null, +): VfsImageMetadata | null { return metadata === null ? null : { ...metadata }; } @@ -127,15 +150,22 @@ function validateMetadata(metadata: VfsImageMetadata): VfsImageMetadata { throw new Error("VFS image metadata must be an object"); } if (metadata.version !== 1) { - throw new Error(`Unsupported VFS image metadata version: ${String(metadata.version)}`); + throw new Error( + `Unsupported VFS image metadata version: ${String(metadata.version)}`, + ); } if ( metadata.kernelAbi !== undefined && (!Number.isInteger(metadata.kernelAbi) || metadata.kernelAbi < 0) ) { - throw new Error(`VFS image metadata kernelAbi must be a non-negative integer`); + throw new Error( + `VFS image metadata kernelAbi must be a non-negative integer`, + ); } - if (metadata.createdBy !== undefined && typeof metadata.createdBy !== "string") { + if ( + metadata.createdBy !== undefined && + typeof metadata.createdBy !== "string" + ) { throw new Error("VFS image metadata createdBy must be a string"); } return { ...metadata }; @@ -196,11 +226,7 @@ function parseImageHeader(input: Uint8Array): ParsedImageHeader { throw new Error("VFS image too small"); } - const view = new DataView( - image.buffer, - image.byteOffset, - image.byteLength, - ); + const view = new DataView(image.buffer, image.byteOffset, image.byteLength); const magic = view.getUint32(0, true); if (magic !== VFS_IMAGE_MAGIC) { throw new Error( @@ -270,12 +296,23 @@ function concatChunks(chunks: Uint8Array[], total: number): Uint8Array { export class MemoryFileSystem implements FileSystemBackend { private fs: SharedFS; private imageMetadata: VfsImageMetadata | null; - /** Lazy files: inode → { path, url, size }. Cleared per-inode after materialization. */ - private lazyFiles = new Map(); + /** Lazy files keyed by inode slot + generation (raw inode numbers are reused). */ + private lazyFiles = new Map< + string, + { + ino: number; + generation: number; + dataSequence: number; + path: string; + paths: Set; + url: string; + size: number; + } + >(); /** Lazy archive groups (bundle of files backed by one zip URL). */ private lazyArchiveGroups: LazyArchiveGroup[] = []; - /** Fast lookup: inode → group it belongs to. Cleared per-group after materialization. */ - private lazyArchiveInodes = new Map(); + /** Fast lookup keyed by inode slot + generation. */ + private lazyArchiveInodes = new Map(); private lazyDownloadListeners = new Set(); private constructor(fs: SharedFS, metadata: VfsImageMetadata | null = null) { @@ -283,12 +320,177 @@ export class MemoryFileSystem implements FileSystemBackend { this.imageMetadata = metadata; } + private static inodeKey(ino: number, generation: number): string { + return `${ino}:${generation}`; + } + + private static canAdoptLegacyLazyStub(st: SfsStatResult): boolean { + // Images from before data-sequence tracking stored regular lazy entries as + // untouched zero-length stubs. Current registration performs one initial + // O_TRUNC, so any later mutation sequence (or concrete bytes) is unsafe to + // associate with metadata that cannot name the content version it saw. + return ( + (st.mode & S_IFMT) === S_IFREG && st.size === 0 && st.dataSequence <= 1 + ); + } + + /** + * Reconcile process-local lazy metadata with authoritative SharedFS names. + * The identity map may come from the same transaction as a filesystem + * snapshot, so callers can serialize matching bytes and lazy paths. + */ + private reconcileLazyIdentityState( + identities: Map, + ): void { + for (const [key, entry] of this.lazyFiles) { + const identity = identities.get(key); + if ( + !identity || + identity.dataSequence !== entry.dataSequence || + identity.paths.length === 0 + ) { + this.lazyFiles.delete(key); + continue; + } + entry.paths = new Set(identity.paths); + if (!entry.paths.has(entry.path)) { + entry.path = identity.paths[0]; + } + } + + this.lazyArchiveInodes.clear(); + for (const group of this.lazyArchiveGroups) { + const pendingByIdentity = new Map(); + for (const entry of group.entries.values()) { + if ( + entry.deleted || + entry.materialized || + entry.generation === undefined + ) + continue; + const key = MemoryFileSystem.inodeKey(entry.ino, entry.generation); + if (!pendingByIdentity.has(key)) pendingByIdentity.set(key, entry); + } + + const reconciled = new Map(); + for (const [key, entry] of pendingByIdentity) { + const identity = identities.get(key); + if (!identity || identity.dataSequence !== (entry.dataSequence ?? 0)) + continue; + for (const path of identity.paths) { + reconciled.set(path, { + ...entry, + ino: identity.ino, + generation: identity.generation, + dataSequence: identity.dataSequence, + deleted: false, + materialized: false, + }); + } + if (identity.paths.length > 0) { + this.lazyArchiveInodes.set(key, group); + } + } + group.entries = reconciled; + group.materialized = reconciled.size === 0; + } + } + + private lazyFileForStat(st: SfsStatResult) { + const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const entry = this.lazyFiles.get(key); + if (entry && entry.dataSequence !== st.dataSequence) { + this.lazyFiles.delete(key); + return undefined; + } + return entry; + } + + private lazyArchiveForStat(st: SfsStatResult) { + const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const group = this.lazyArchiveInodes.get(key); + if (!group) return undefined; + const entries = Array.from(group.entries.values()).filter( + (entry) => + entry.ino === st.ino && + entry.generation === st.generation && + !entry.deleted && + !entry.materialized, + ); + if (entries.some((entry) => entry.dataSequence === st.dataSequence)) { + return group; + } + this.lazyArchiveInodes.delete(key); + for (const entry of entries) entry.materialized = true; + return undefined; + } + + /** A successful guest data mutation makes any deferred backing obsolete. */ + private invalidateLazyData(st: SfsStatResult): void { + const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + this.lazyFiles.delete(key); + + const group = this.lazyArchiveInodes.get(key); + if (!group) return; + this.lazyArchiveInodes.delete(key); + for (const entry of group.entries.values()) { + if (entry.ino === st.ino && entry.generation === st.generation) { + // Keep the concrete inode in the image, but prevent a later archive + // fetch from overwriting data the guest supplied through any alias. + entry.materialized = true; + } + } + } + + private rewriteLazyNamespacePaths( + source: NamespaceEntryIdentity, + oldPath: string, + newPath: string, + ): void { + const oldBase = oldPath.length > 1 ? oldPath.replace(/\/+$/, "") : oldPath; + const newBase = newPath.length > 1 ? newPath.replace(/\/+$/, "") : newPath; + const oldPrefix = `${oldBase}/`; + const newPrefix = `${newBase}/`; + const sourceKey = MemoryFileSystem.inodeKey(source.ino, source.generation); + const directory = (source.mode & S_IFMT) === S_IFDIR; + const rewrite = (candidate: string): string => + candidate === oldBase + ? newBase + : directory && candidate.startsWith(oldPrefix) + ? newPrefix + candidate.slice(oldPrefix.length) + : candidate; + + for (const [key, lazy] of this.lazyFiles) { + if (!directory && key !== sourceKey) continue; + lazy.paths = new Set(Array.from(lazy.paths, rewrite)); + lazy.path = rewrite(lazy.path); + } + + for (const group of this.lazyArchiveGroups) { + const rewritten = new Map(); + for (const [candidate, entry] of group.entries) { + const entryKey = + entry.generation === undefined + ? null + : MemoryFileSystem.inodeKey(entry.ino, entry.generation); + rewritten.set( + directory || entryKey === sourceKey ? rewrite(candidate) : candidate, + entry, + ); + } + group.entries = rewritten; + } + } + /** Return the underlying SharedArrayBuffer (for sharing with workers). */ get sharedBuffer(): SharedArrayBuffer { return this.fs.buffer as SharedArrayBuffer; } - static create(sab: SharedArrayBuffer, maxSizeBytes?: number): MemoryFileSystem { + static create( + sab: SharedArrayBuffer, + maxSizeBytes?: number, + ): MemoryFileSystem { return new MemoryFileSystem(SharedFS.mkfs(sab, maxSizeBytes)); } @@ -303,24 +505,43 @@ export class MemoryFileSystem implements FileSystemBackend { */ rebaseToNewFileSystem(maxByteLength: number): MemoryFileSystem { if (!Number.isSafeInteger(maxByteLength) || maxByteLength <= 0) { - throw new Error(`Invalid MemoryFileSystem maxByteLength: ${maxByteLength}`); + throw new Error( + `Invalid MemoryFileSystem maxByteLength: ${maxByteLength}`, + ); } - const initialByteLength = Math.min( - maxByteLength, - Math.max(this.sharedBuffer.byteLength, MIN_REBASE_INITIAL_BYTES), - ); const SharedArrayBufferCtor = SharedArrayBuffer as new ( byteLength: number, options?: { maxByteLength?: number }, ) => SharedArrayBuffer; + + // Copy from one quiescent source image. Exporting lazy paths and then + // walking the live SAB would let a peer rename an entry between those two + // operations, making the logical lazy size disagree with the copied path. + const { bytes: sourceBytes, identities } = this.fs.snapshotState(); + this.reconcileLazyIdentityState(identities); + const lazyEntries = this.serializeLazyEntries(); + const lazyArchiveEntries = this.serializeLazyArchiveEntries(); + const sourceSab = new SharedArrayBufferCtor(sourceBytes.byteLength); + new Uint8Array(sourceSab).set(sourceBytes); + const source = new MemoryFileSystem( + SharedFS.mount(sourceSab, { restoreImage: true }), + this.imageMetadata, + ); + source.importLazyEntries(lazyEntries); + source.importLazyArchiveEntries(lazyArchiveEntries); + + const initialByteLength = Math.min( + maxByteLength, + Math.max(sourceBytes.byteLength, MIN_REBASE_INITIAL_BYTES), + ); const sab = new SharedArrayBufferCtor(initialByteLength, { maxByteLength }); const target = MemoryFileSystem.create(sab, maxByteLength); target.setImageMetadata(this.imageMetadata); - const lazyEntries = this.exportLazyEntries(); - const lazyFilePaths = new Set(lazyEntries.map((entry) => entry.path)); - const lazyArchiveEntries = this.exportLazyArchiveEntries(); + const lazyFilePaths = new Set( + lazyEntries.flatMap((entry) => entry.paths ?? [entry.path]), + ); const lazyArchiveStubPaths = new Set(); for (const group of lazyArchiveEntries) { if (group.materialized) continue; @@ -331,19 +552,40 @@ export class MemoryFileSystem implements FileSystemBackend { } } - this.copyPathToFreshFileSystem("/", target, lazyFilePaths, lazyArchiveStubPaths); - - target.importLazyEntries(lazyEntries.map((entry) => ({ - ...entry, - ino: target.lstat(entry.path).ino, - }))); - target.importLazyArchiveEntries(lazyArchiveEntries.map((group) => ({ - ...group, - entries: group.entries.map((entry) => ({ - ...entry, - ino: entry.deleted ? 0 : target.lstat(entry.vfsPath).ino, + source.copyPathToFreshFileSystem( + "/", + target, + lazyFilePaths, + lazyArchiveStubPaths, + new Map(), + ); + + target.importLazyEntries( + lazyEntries.map((entry) => { + const st = target.fs.lstat(entry.path); + return { + ...entry, + ino: st.ino, + generation: st.generation, + dataSequence: st.dataSequence, + }; + }), + ); + target.importLazyArchiveEntries( + lazyArchiveEntries.map((group) => ({ + ...group, + entries: group.entries.map((entry) => { + if (entry.deleted) return { ...entry, ino: 0, generation: undefined }; + const st = target.fs.lstat(entry.vfsPath); + return { + ...entry, + ino: st.ino, + generation: st.generation, + dataSequence: st.dataSequence, + }; + }), })), - }))); + ); return target; } @@ -367,20 +609,22 @@ export class MemoryFileSystem implements FileSystemBackend { if (this.lazyDownloadListeners.size === 0) return; const stamped: LazyDownloadEvent = { ...event, t: monotonicNow() }; for (const listener of this.lazyDownloadListeners) { - try { listener(stamped); } catch { /* listener errors must not break VFS I/O */ } + try { + listener(stamped); + } catch { + /* listener errors must not break VFS I/O */ + } } } - private async fetchLazyBytes( - details: { - id: string; - kind: LazyDownloadKind; - url: string; - path?: string; - mountPrefix?: string; - fallbackTotalBytes?: number; - }, - ): Promise { + private async fetchLazyBytes(details: { + id: string; + kind: LazyDownloadKind; + url: string; + path?: string; + mountPrefix?: string; + fallbackTotalBytes?: number; + }): Promise { let loadedBytes = 0; let totalBytes = details.fallbackTotalBytes; const base = { @@ -466,23 +710,38 @@ export class MemoryFileSystem implements FileSystemBackend { /** * Register a lazy file: creates an empty stub in SharedFS and records - * metadata so that read() will fetch content on demand via sync XHR. + * metadata for ensureMaterialized() to fetch asynchronously before a + * synchronous read or exec path consumes the file. * Returns the inode number (useful for forwarding to other instances). */ - registerLazyFile(path: string, url: string, size: number, mode = 0o755): number { + registerLazyFile( + path: string, + url: string, + size: number, + mode = 0o755, + ): number { // Ensure parent directories exist const parts = path.split("/").filter(Boolean); let current = ""; for (let i = 0; i < parts.length - 1; i++) { current += "/" + parts[i]; - try { this.fs.mkdir(current, 0o755); } catch { /* exists */ } + try { + this.fs.mkdir(current, 0o755); + } catch { + /* exists */ + } } - // Create empty stub file - const fd = this.fs.open(path, 0o1101, mode); // O_WRONLY | O_CREAT | O_TRUNC - this.fs.close(fd); - // Get inode - const st = this.fs.stat(path); - this.lazyFiles.set(st.ino, { path, url, size }); + const st = this.fs.createLazyStub(path, mode); + this.invalidateLazyData(st); + this.lazyFiles.set(MemoryFileSystem.inodeKey(st.ino, st.generation), { + ino: st.ino, + generation: st.generation, + dataSequence: st.dataSequence, + path, + paths: new Set([path]), + url, + size, + }); return st.ino; } @@ -491,26 +750,105 @@ export class MemoryFileSystem implements FileSystemBackend { * Does not create files — assumes the files already exist in the SharedArrayBuffer. */ importLazyEntries(entries: LazyFileEntry[]): void { + this.importLazyEntriesInternal(entries, false); + } + + private importLazyEntriesInternal( + entries: LazyFileEntry[], + trustedLegacySnapshot: boolean, + ): void { for (const e of entries) { - this.lazyFiles.set(e.ino, { path: e.path, url: e.url, size: e.size }); + const isLegacy = + e.generation === undefined || e.dataSequence === undefined; + if (isLegacy && !trustedLegacySnapshot) { + throw new Error( + "Live lazy-file metadata requires inode generation and data sequence", + ); + } + const validPaths = new Set(); + let identity: SfsStatResult | null = null; + for (const path of new Set([e.path, ...(e.paths ?? [])])) { + let st: SfsStatResult; + try { + st = this.fs.stat(path); + } catch { + continue; + } + if (st.ino !== e.ino) continue; + if (e.generation !== undefined && st.generation !== e.generation) { + continue; + } + if (e.dataSequence === undefined) { + if (!MemoryFileSystem.canAdoptLegacyLazyStub(st)) continue; + } else if (st.dataSequence !== e.dataSequence) continue; + identity ??= st; + validPaths.add(path); + } + if (!identity || validPaths.size === 0) continue; + const primaryPath = validPaths.has(e.path) + ? e.path + : validPaths.values().next().value!; + this.lazyFiles.set( + MemoryFileSystem.inodeKey(identity.ino, identity.generation), + { + ino: identity.ino, + generation: identity.generation, + dataSequence: identity.dataSequence, + path: primaryPath, + paths: validPaths, + url: e.url, + size: e.size, + }, + ); } } - /** Export all pending lazy entries for transfer to another instance. */ - exportLazyEntries(): LazyFileEntry[] { + private serializeLazyEntries(): LazyFileEntry[] { const entries: LazyFileEntry[] = []; - for (const [ino, { path, url, size }] of this.lazyFiles) { - entries.push({ ino, path, url, size }); + for (const { + ino, + generation, + dataSequence, + path, + paths, + url, + size, + } of this.lazyFiles.values()) { + entries.push({ + ino, + generation, + dataSequence, + path, + paths: Array.from(paths), + url, + size, + }); } return entries; } + /** Export all pending lazy entries for transfer to another instance. */ + exportLazyEntries(): LazyFileEntry[] { + this.reconcileLazyIdentityState(this.fs.identityState()); + return this.serializeLazyEntries(); + } + /** Return lazy metadata for `path`, following symlinks through stat(). */ getLazyEntry(path: string): LazyFileEntry | null { try { const st = this.fs.stat(path); - const entry = this.lazyFiles.get(st.ino); - return entry ? { ino: st.ino, path: entry.path, url: entry.url, size: entry.size } : null; + const entry = this.lazyFileForStat(st); + return entry + ? { + ino: st.ino, + generation: st.generation, + dataSequence: st.dataSequence, + path: entry.path, + paths: Array.from(entry.paths), + url: entry.url, + size: entry.size, + } + : null; } catch { return null; } @@ -522,11 +860,8 @@ export class MemoryFileSystem implements FileSystemBackend { * them with bundler-produced asset URLs. */ rewriteLazyFileUrls(transform: (url: string, path: string) => string): void { - for (const [ino, entry] of this.lazyFiles) { - this.lazyFiles.set(ino, { - ...entry, - url: transform(entry.url, entry.path), - }); + for (const entry of this.lazyFiles.values()) { + entry.url = transform(entry.url, entry.path); } } @@ -562,55 +897,132 @@ export class MemoryFileSystem implements FileSystemBackend { let current = ""; for (let i = 0; i < parts.length - 1; i++) { current += "/" + parts[i]; - try { this.fs.mkdir(current, 0o755); } catch { /* exists */ } + try { + this.fs.mkdir(current, 0o755); + } catch { + /* exists */ + } } - if (ze.isSymlink && symlinkTargets?.has(ze.fileName)) { + if (ze.isSymlink) { + if (!symlinkTargets?.has(ze.fileName)) { + throw new Error( + `Lazy archive symlink target was not provided: ${ze.fileName}`, + ); + } const target = symlinkTargets.get(ze.fileName)!; this.fs.symlink(target, vfsPath); + const st = this.fs.lstat(vfsPath); + const entry: LazyArchiveFileEntry = { + ino: st.ino, + generation: st.generation, + dataSequence: st.dataSequence, + size: ze.uncompressedSize, + isSymlink: true, + deleted: false, + materialized: true, + archivePath: ze.fileName, + }; + group.entries.set(vfsPath, entry); } else { - const fd = this.fs.open(vfsPath, 0o1101, ze.mode); // O_WRONLY | O_CREAT | O_TRUNC - this.fs.close(fd); + const st = this.fs.createLazyStub(vfsPath, ze.mode); + this.invalidateLazyData(st); + const entry: LazyArchiveFileEntry = { + ino: st.ino, + generation: st.generation, + dataSequence: st.dataSequence, + size: ze.uncompressedSize, + isSymlink: false, + deleted: false, + materialized: false, + archivePath: ze.fileName, + }; + group.entries.set(vfsPath, entry); + this.lazyArchiveInodes.set( + MemoryFileSystem.inodeKey(st.ino, st.generation), + group, + ); } - - const st = this.fs.lstat(vfsPath); - const entry: LazyArchiveFileEntry = { - ino: st.ino, - size: ze.uncompressedSize, - isSymlink: ze.isSymlink, - deleted: false, - }; - group.entries.set(vfsPath, entry); - this.lazyArchiveInodes.set(st.ino, group); } + group.materialized = Array.from(group.entries.values()).every( + (entry) => entry.deleted || entry.materialized, + ); this.lazyArchiveGroups.push(group); return group; } /** Import lazy archive groups from another instance. Assumes stubs already exist. */ importLazyArchiveEntries(serialized: SerializedLazyArchiveEntry[]): void { + this.importLazyArchiveEntriesInternal(serialized, false); + } + + private importLazyArchiveEntriesInternal( + serialized: SerializedLazyArchiveEntry[], + trustedLegacySnapshot: boolean, + ): void { for (const s of serialized) { const entries = new Map(); + const normalizedPrefix = s.mountPrefix.replace(/\/+$/, ""); for (const e of s.entries) { + let st: SfsStatResult | null = null; + const materialized = + s.materialized || e.materialized === true || e.isSymlink; + if (!e.deleted && !materialized) { + const isLegacy = + e.generation === undefined || e.dataSequence === undefined; + if (isLegacy && !trustedLegacySnapshot) { + throw new Error( + "Live lazy-archive metadata requires inode generation and data sequence", + ); + } + try { + st = this.fs.lstat(e.vfsPath); + } catch { + continue; + } + if (st.ino !== e.ino) continue; + if (e.generation !== undefined && st.generation !== e.generation) { + continue; + } + if (e.dataSequence === undefined) { + if (!MemoryFileSystem.canAdoptLegacyLazyStub(st)) continue; + } else if (st.dataSequence !== e.dataSequence) continue; + } entries.set(e.vfsPath, { ino: e.ino, + generation: st?.generation ?? e.generation, + dataSequence: st?.dataSequence ?? e.dataSequence, size: e.size, isSymlink: e.isSymlink, deleted: e.deleted, + materialized, + archivePath: + e.archivePath ?? e.vfsPath.slice(normalizedPrefix.length + 1), }); } const group: LazyArchiveGroup = { url: s.url, mountPrefix: s.mountPrefix, - materialized: s.materialized, + materialized: + s.materialized || + Array.from(entries.values()).every( + (entry) => entry.deleted || entry.materialized, + ), entries, }; this.lazyArchiveGroups.push(group); if (!group.materialized) { for (const [, entry] of entries) { - if (!entry.deleted) { - this.lazyArchiveInodes.set(entry.ino, group); + if ( + !entry.deleted && + !entry.materialized && + entry.generation !== undefined + ) { + this.lazyArchiveInodes.set( + MemoryFileSystem.inodeKey(entry.ino, entry.generation), + group, + ); } } } @@ -628,20 +1040,35 @@ export class MemoryFileSystem implements FileSystemBackend { } } - /** Export all lazy archive groups for transfer to another instance. */ - exportLazyArchiveEntries(): SerializedLazyArchiveEntry[] { - return this.lazyArchiveGroups.map((group) => ({ - url: group.url, - mountPrefix: group.mountPrefix, - materialized: group.materialized, - entries: Array.from(group.entries, ([vfsPath, entry]) => ({ + private serializeLazyArchiveEntries(): SerializedLazyArchiveEntry[] { + const serialized: SerializedLazyArchiveEntry[] = []; + for (const group of this.lazyArchiveGroups) { + const entries = Array.from(group.entries, ([vfsPath, entry]) => ({ vfsPath, ino: entry.ino, + generation: entry.generation, + dataSequence: entry.dataSequence, size: entry.size, isSymlink: entry.isSymlink, deleted: entry.deleted, - })), - })); + materialized: entry.materialized, + archivePath: entry.archivePath, + })).filter((entry) => !entry.deleted && !entry.materialized); + if (entries.length === 0) continue; + serialized.push({ + url: group.url, + mountPrefix: group.mountPrefix, + materialized: false, + entries, + }); + } + return serialized; + } + + /** Export all pending lazy archive groups for transfer to another instance. */ + exportLazyArchiveEntries(): SerializedLazyArchiveEntry[] { + this.reconcileLazyIdentityState(this.fs.identityState()); + return this.serializeLazyArchiveEntries(); } /** @@ -651,33 +1078,58 @@ export class MemoryFileSystem implements FileSystemBackend { * Returns true if something was materialized, false if already concrete. */ async ensureMaterialized(path: string): Promise { - if (this.lazyFiles.size === 0 && this.lazyArchiveInodes.size === 0) return false; - try { - const st = this.fs.stat(path); // follows symlinks - const entry = this.lazyFiles.get(st.ino); - if (entry) { - const data = await this.fetchLazyBytes({ - id: `file:${st.ino}`, - kind: "file", - url: entry.url, - path: entry.path, - fallbackTotalBytes: entry.size, - }); - const fd = this.fs.open(entry.path, 0o1101, 0o755); // O_WRONLY | O_CREAT | O_TRUNC - this.fs.write(fd, data); - this.fs.close(fd); - this.lazyFiles.delete(st.ino); - return true; - } - const group = this.lazyArchiveInodes.get(st.ino); - if (group) { - await this.ensureArchiveMaterialized(group); - return true; - } + if (this.lazyFiles.size === 0 && this.lazyArchiveInodes.size === 0) return false; + let st: SfsStatResult; + try { + st = this.fs.stat(path); // follows symlinks } catch { return false; } + const key = MemoryFileSystem.inodeKey(st.ino, st.generation); + const entry = this.lazyFiles.get(key); + if (entry) { + const data = await this.fetchLazyBytes({ + id: `file:${st.ino}`, + kind: "file", + url: entry.url, + path: entry.path, + fallbackTotalBytes: entry.size, + }); + for (let attempt = 0; attempt < 3; attempt++) { + if (this.lazyFiles.get(key) !== entry) return false; + for (const candidate of new Set([path, ...entry.paths])) { + const materialized = this.fs.replaceIfIdentity( + candidate, + entry.ino, + entry.generation, + entry.dataSequence, + data, + ); + if (materialized) { + entry.path = candidate; + this.lazyFiles.delete(key); + return true; + } + } + // A peer may have renamed the inode while the fetch was in flight. + // Refresh aliases and retry immediately with the bytes already read. + this.reconcileLazyIdentityState(this.fs.identityState()); + } + throw new Error( + `Lazy file kept changing names while materializing: ${path}`, + ); + } + const group = this.lazyArchiveInodes.get(key); + if (group) { + await this.ensureArchiveMaterialized(group, { + path, + ino: st.ino, + generation: st.generation, + }); + return !this.lazyArchiveInodes.has(key); + } + return false; } /** @@ -685,7 +1137,10 @@ export class MemoryFileSystem implements FileSystemBackend { * central directory, and write every non-deleted entry into its stub. * Subsequent calls are no-ops. */ - async ensureArchiveMaterialized(group: LazyArchiveGroup): Promise { + async ensureArchiveMaterialized( + group: LazyArchiveGroup, + requested?: { path: string; ino: number; generation: number }, + ): Promise { if (group.materialized) return; const zipData = await this.fetchLazyBytes({ @@ -701,21 +1156,98 @@ export class MemoryFileSystem implements FileSystemBackend { for (const ze of zipEntries) zipLookup.set(ze.fileName, ze); const normalizedPrefix = group.mountPrefix.replace(/\/+$/, ""); - for (const [vfsPath, archiveEntry] of group.entries) { - if (archiveEntry.deleted) continue; - if (archiveEntry.isSymlink) continue; // symlinks already created at registration - const zipFileName = vfsPath.slice(normalizedPrefix.length + 1); - const ze = zipLookup.get(zipFileName); - if (!ze) continue; - const content = extractZipEntry(zipData, ze); - const fd = this.fs.open(vfsPath, 0o1101, 0o755); // O_WRONLY | O_CREAT | O_TRUNC - if (content.length > 0) this.fs.write(fd, content); - this.fs.close(fd); + const requestedKey = requested + ? MemoryFileSystem.inodeKey(requested.ino, requested.generation) + : null; + for (let attempt = 0; attempt < 3; attempt++) { + for (const [vfsPath, archiveEntry] of group.entries) { + if (archiveEntry.deleted || archiveEntry.materialized) continue; + if (archiveEntry.isSymlink) { + archiveEntry.materialized = true; + continue; + } + const zipFileName = + archiveEntry.archivePath ?? + vfsPath.slice(normalizedPrefix.length + 1); + const ze = zipLookup.get(zipFileName); + if (!ze) continue; + const content = extractZipEntry(zipData, ze); + if (archiveEntry.generation === undefined) continue; + const key = MemoryFileSystem.inodeKey( + archiveEntry.ino, + archiveEntry.generation, + ); + if (this.lazyArchiveInodes.get(key) !== group) continue; + const candidates = new Set([vfsPath]); + if ( + requested && + requested.ino === archiveEntry.ino && + requested.generation === archiveEntry.generation + ) + candidates.add(requested.path); + let materialized = false; + for (const candidate of candidates) { + materialized = this.fs.replaceIfIdentity( + candidate, + archiveEntry.ino, + archiveEntry.generation, + archiveEntry.dataSequence ?? 0, + content, + ); + if (materialized) break; + } + if (!materialized) continue; + this.lazyArchiveInodes.delete(key); + for (const alias of group.entries.values()) { + if ( + alias.ino === archiveEntry.ino && + alias.generation === archiveEntry.generation + ) + alias.materialized = true; + } + } + + group.materialized = Array.from(group.entries.values()).every( + (entry) => entry.deleted || entry.materialized, + ); + if (group.materialized) return; + this.reconcileLazyIdentityState(this.fs.identityState()); + if (requestedKey && !this.lazyArchiveInodes.has(requestedKey)) return; + } + + if (requestedKey && this.lazyArchiveInodes.has(requestedKey)) { + throw new Error( + `Lazy archive member kept changing names while materializing: ${requested?.path}`, + ); + } + } + + private async materializeAllLazyEntries(): Promise { + // A peer can rename an inode while an asynchronous fetch is in flight. + // Refresh and retry a bounded number of times; a continuously mutating + // filesystem is not a stable source for a self-contained image. + for (let attempt = 0; attempt < 3; attempt++) { + this.reconcileLazyIdentityState(this.fs.identityState()); + if (this.lazyFiles.size === 0 && this.lazyArchiveInodes.size === 0) + return; + + const filePaths = Array.from( + this.lazyFiles.values(), + (entry) => entry.path, + ); + for (const path of filePaths) await this.ensureMaterialized(path); + + const archiveGroups = new Set(this.lazyArchiveInodes.values()); + for (const group of archiveGroups) { + await this.ensureArchiveMaterialized(group); + } } - group.materialized = true; - for (const [, archiveEntry] of group.entries) { - this.lazyArchiveInodes.delete(archiveEntry.ino); + this.reconcileLazyIdentityState(this.fs.identityState()); + if (this.lazyFiles.size !== 0 || this.lazyArchiveInodes.size !== 0) { + throw new Error( + "Cannot create a self-contained VFS image while lazy entries remain pending", + ); } } @@ -729,28 +1261,25 @@ export class MemoryFileSystem implements FileSystemBackend { */ async saveImage(options?: VfsImageOptions): Promise { if (options?.materializeAll) { - const paths = Array.from(this.lazyFiles.values()).map((e) => e.path); - for (const p of paths) { - await this.ensureMaterialized(p); - } + await this.materializeAllLazyEntries(); } - const sabBytes = new Uint8Array(this.fs.buffer); - const lazyEntries = this.exportLazyEntries(); + const { bytes: sabBytes, identities } = this.fs.snapshotState(); + this.reconcileLazyIdentityState(identities); + const lazyEntries = this.serializeLazyEntries(); const hasLazy = lazyEntries.length > 0; const lazyJson = hasLazy ? new TextEncoder().encode(JSON.stringify(lazyEntries)) : new Uint8Array(0); - const archiveEntries = this.exportLazyArchiveEntries(); + const archiveEntries = this.serializeLazyArchiveEntries(); const hasArchives = archiveEntries.length > 0; const archiveJson = hasArchives ? new TextEncoder().encode(JSON.stringify(archiveEntries)) : new Uint8Array(0); - const metadata = options?.metadata === undefined - ? this.imageMetadata - : options.metadata; + const metadata = + options?.metadata === undefined ? this.imageMetadata : options.metadata; const metadataJson = encodeMetadata(metadata); const hasMetadata = metadataJson.byteLength > 0; @@ -760,11 +1289,11 @@ export class MemoryFileSystem implements FileSystemBackend { const metadataSectionSize = hasMetadata ? 4 + metadataJson.byteLength : 0; const totalSize = VFS_IMAGE_HEADER_SIZE + - sabBytes.byteLength + - 4 + - lazyJson.byteLength + - archiveSectionSize + - metadataSectionSize; + sabBytes.byteLength + + 4 + + lazyJson.byteLength + + archiveSectionSize + + metadataSectionSize; const image = new Uint8Array(totalSize); const view = new DataView(image.buffer); @@ -780,10 +1309,8 @@ export class MemoryFileSystem implements FileSystemBackend { ); view.setUint32(12, sabBytes.byteLength, true); - // SAB data — copy from SharedArrayBuffer (can't use set() directly on SAB-backed views in all environments) - const sabCopy = new Uint8Array(sabBytes.byteLength); - sabCopy.set(sabBytes); - image.set(sabCopy, VFS_IMAGE_HEADER_SIZE); + // SAB data is already a detached, runtime-state-free snapshot. + image.set(sabBytes, VFS_IMAGE_HEADER_SIZE); // Lazy entries const lazyOffset = VFS_IMAGE_HEADER_SIZE + sabBytes.byteLength; @@ -801,7 +1328,8 @@ export class MemoryFileSystem implements FileSystemBackend { // Metadata if (hasMetadata) { - const metadataOffset = lazyOffset + 4 + lazyJson.byteLength + archiveSectionSize; + const metadataOffset = + lazyOffset + 4 + lazyJson.byteLength + archiveSectionSize; view.setUint32(metadataOffset, metadataJson.byteLength, true); image.set(metadataJson, metadataOffset + 4); } @@ -833,7 +1361,10 @@ export class MemoryFileSystem implements FileSystemBackend { } if (metadataLen === 0) return null; return decodeMetadata( - parsed.image.subarray(metadataOffset + 4, metadataOffset + 4 + metadataLen), + parsed.image.subarray( + metadataOffset + 4, + metadataOffset + 4 + metadataLen, + ), ); } @@ -866,7 +1397,10 @@ export class MemoryFileSystem implements FileSystemBackend { * so the filesystem can expand beyond the image's original size, up to the * maximum already recorded in the image superblock. */ - static fromImage(image: Uint8Array, options?: { maxByteLength?: number }): MemoryFileSystem { + static fromImage( + image: Uint8Array, + options?: { maxByteLength?: number }, + ): MemoryFileSystem { const parsed = parseImageHeader(image); image = parsed.image; const view = parsed.view; @@ -885,25 +1419,33 @@ export class MemoryFileSystem implements FileSystemBackend { ) => SharedArrayBuffer; const sab = new SharedArrayBufferCtor(sabLen, sabOptions); const sabView = new Uint8Array(sab); - sabView.set(image.subarray(VFS_IMAGE_HEADER_SIZE, VFS_IMAGE_HEADER_SIZE + sabLen)); + sabView.set( + image.subarray(VFS_IMAGE_HEADER_SIZE, VFS_IMAGE_HEADER_SIZE + sabLen), + ); let metadata: VfsImageMetadata | null = null; if (flags & VFS_IMAGE_FLAG_HAS_METADATA) { metadata = MemoryFileSystem.readImageMetadata(image); } - const mfs = new MemoryFileSystem(SharedFS.mount(sab), metadata); + const mfs = new MemoryFileSystem( + SharedFS.mount(sab, { restoreImage: true }), + metadata, + ); // Restore lazy entries const lazyOffset = VFS_IMAGE_HEADER_SIZE + sabLen; const lazyLen = view.getUint32(lazyOffset, true); if (flags & VFS_IMAGE_FLAG_HAS_LAZY) { if (lazyLen > 0) { - const lazyBytes = image.subarray(lazyOffset + 4, lazyOffset + 4 + lazyLen); + const lazyBytes = image.subarray( + lazyOffset + 4, + lazyOffset + 4 + lazyLen, + ); const entries: LazyFileEntry[] = JSON.parse( new TextDecoder().decode(lazyBytes), ); - mfs.importLazyEntries(entries); + mfs.importLazyEntriesInternal(entries, true); } } @@ -922,7 +1464,7 @@ export class MemoryFileSystem implements FileSystemBackend { const entries: SerializedLazyArchiveEntry[] = JSON.parse( new TextDecoder().decode(archiveBytes), ); - mfs.importLazyArchiveEntries(entries); + mfs.importLazyArchiveEntriesInternal(entries, true); } } @@ -944,8 +1486,37 @@ export class MemoryFileSystem implements FileSystemBackend { }; } + private adaptStatWithLazySize(s: SfsStatResult): StatResult { + const result = this.adaptStat(s); + const entry = this.lazyFileForStat(s); + if (entry) { + result.size = entry.size; + return result; + } + + const group = this.lazyArchiveForStat(s); + if (group) { + for (const archiveEntry of group.entries.values()) { + if ( + archiveEntry.ino === s.ino && + archiveEntry.generation === s.generation && + !archiveEntry.deleted + ) { + result.size = archiveEntry.size; + break; + } + } + } + return result; + } + open(path: string, flags: number, mode: number): number { - return this.fs.open(path, flags, mode); + const handle = this.fs.open(path, flags, mode); + if ((flags & 0x0200) !== 0) { + // O_TRUNC + this.invalidateLazyData(this.fs.fstat(handle)); + } + return handle; } close(handle: number): number { @@ -982,9 +1553,12 @@ export class MemoryFileSystem implements FileSystemBackend { this.fs.lseek(handle, offset, 0); // SEEK_SET const n = this.fs.write(handle, buffer.subarray(0, length)); this.fs.lseek(handle, savedPos, 0); // restore position + if (n > 0) this.invalidateLazyData(this.fs.fstat(handle)); return n; } - return this.fs.write(handle, buffer.subarray(0, length)); + const n = this.fs.write(handle, buffer.subarray(0, length)); + if (n > 0) this.invalidateLazyData(this.fs.fstat(handle)); + return n; } seek(handle: number, offset: number, whence: number): number { @@ -992,27 +1566,20 @@ export class MemoryFileSystem implements FileSystemBackend { } fstat(handle: number): StatResult { - const result = this.adaptStat(this.fs.fstat(handle)); - // Override size for unmaterialized lazy files / archive entries - const entry = this.lazyFiles.get(result.ino); - if (entry) { - result.size = entry.size; - } else { - const group = this.lazyArchiveInodes.get(result.ino); - if (group) { - for (const archiveEntry of group.entries.values()) { - if (archiveEntry.ino === result.ino) { - result.size = archiveEntry.size; - break; - } - } - } - } - return result; + return this.adaptStatWithLazySize(this.fs.fstat(handle)); + } + + fpathconf(handle: number, name: number): PathconfValue { + const stat = this.fstat(handle); + return filesystemPathconf(stat, name, { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }); } ftruncate(handle: number, length: number): void { this.fs.ftruncate(handle, length); + this.invalidateLazyData(this.fs.fstat(handle)); } // SharedFS is memory-backed, fsync is a no-op @@ -1026,43 +1593,11 @@ export class MemoryFileSystem implements FileSystemBackend { } stat(path: string): StatResult { - const result = this.adaptStat(this.fs.stat(path)); - // Override size for unmaterialized lazy files / archive entries - const entry = this.lazyFiles.get(result.ino); - if (entry) { - result.size = entry.size; - } else { - const group = this.lazyArchiveInodes.get(result.ino); - if (group) { - for (const archiveEntry of group.entries.values()) { - if (archiveEntry.ino === result.ino) { - result.size = archiveEntry.size; - break; - } - } - } - } - return result; + return this.adaptStatWithLazySize(this.fs.stat(path)); } lstat(path: string): StatResult { - const result = this.adaptStat(this.fs.lstat(path)); - // Override size for unmaterialized lazy files / archive entries - const entry = this.lazyFiles.get(result.ino); - if (entry) { - result.size = entry.size; - } else { - const group = this.lazyArchiveInodes.get(result.ino); - if (group) { - for (const archiveEntry of group.entries.values()) { - if (archiveEntry.ino === result.ino) { - result.size = archiveEntry.size; - break; - } - } - } - } - return result; + return this.adaptStatWithLazySize(this.fs.lstat(path)); } statfs(path: string): StatfsResult { @@ -1083,6 +1618,14 @@ export class MemoryFileSystem implements FileSystemBackend { }; } + pathconf(path: string, name: number): PathconfValue { + const stat = this.stat(path); + return filesystemPathconf(stat, name, { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }); + } + mkdir(path: string, mode: number): void { this.fs.mkdir(path, mode); } @@ -1092,28 +1635,119 @@ export class MemoryFileSystem implements FileSystemBackend { } unlink(path: string): void { - // If the path belongs to an unmaterialized archive group, mark the entry - // as deleted so materialization skips it. - if (this.lazyArchiveInodes.size > 0) { - try { - const st = this.fs.lstat(path); - const group = this.lazyArchiveInodes.get(st.ino); - if (group) { - const entry = group.entries.get(path); - if (entry) entry.deleted = true; - this.lazyArchiveInodes.delete(st.ino); + const removed = this.fs.unlink(path); + const key = MemoryFileSystem.inodeKey(removed.ino, removed.generation); + if ( + removed.linkCount > 1 && + (this.lazyFiles.has(key) || this.lazyArchiveInodes.has(key)) + ) { + // A peer may have added hard-link names this instance never observed. + // Rebuild aliases from SharedFS instead of treating an empty local path + // set as proof that the inode disappeared. + this.reconcileLazyIdentityState(this.fs.identityState()); + return; + } + + const lazy = this.lazyFiles.get(key); + if (lazy) { + lazy.paths.delete(path); + if (removed.linkCount <= 1) { + this.lazyFiles.delete(key); + } else if (lazy.path === path) { + lazy.path = lazy.paths.values().next().value!; + } + } + + const group = this.lazyArchiveInodes.get(key); + if (group) { + const entry = group.entries.get(path); + if (removed.linkCount <= 1) { + for (const candidate of group.entries.values()) { + if ( + candidate.ino === removed.ino && + candidate.generation === removed.generation + ) + candidate.deleted = true; } - } catch { /* not present — unlink will raise the real error */ } + this.lazyArchiveInodes.delete(key); + } else if (entry) { + group.entries.delete(path); + } } - this.fs.unlink(path); } rename(oldPath: string, newPath: string): void { - this.fs.rename(oldPath, newPath); + const { source, replaced } = this.fs.rename(oldPath, newPath); + + if ( + replaced && + replaced.ino === source.ino && + replaced.generation === source.generation + ) + return; + + let reconciledNamespace = false; + if (replaced) { + const replacedKey = MemoryFileSystem.inodeKey( + replaced.ino, + replaced.generation, + ); + if ( + replaced.linkCount > 1 && + (this.lazyFiles.has(replacedKey) || + this.lazyArchiveInodes.has(replacedKey)) + ) { + // The replaced inode survived through a hard link that may have been + // created by a peer. One authoritative reconciliation updates both + // that alias and the source paths changed by rename(). + this.reconcileLazyIdentityState(this.fs.identityState()); + reconciledNamespace = true; + } + + const replacedLazy = this.lazyFiles.get(replacedKey); + if (!reconciledNamespace && replacedLazy) { + replacedLazy.paths.delete(newPath); + if (replaced.linkCount <= 1) { + this.lazyFiles.delete(replacedKey); + } else if (replacedLazy.path === newPath) { + replacedLazy.path = replacedLazy.paths.values().next().value!; + } + } + const replacedGroup = this.lazyArchiveInodes.get(replacedKey); + if (!reconciledNamespace && replacedGroup) { + const entry = replacedGroup.entries.get(newPath); + if (replaced.linkCount <= 1) { + if (entry) entry.deleted = true; + this.lazyArchiveInodes.delete(replacedKey); + } else if (entry) { + replacedGroup.entries.delete(newPath); + } + } + } + + if (!reconciledNamespace) { + this.rewriteLazyNamespacePaths(source, oldPath, newPath); + } } link(existingPath: string, newPath: string): void { - this.fs.link(existingPath, newPath); + const sourceIdentity = this.fs.link(existingPath, newPath); + const key = MemoryFileSystem.inodeKey( + sourceIdentity.ino, + sourceIdentity.generation, + ); + const lazy = this.lazyFiles.get(key); + if (lazy) lazy.paths.add(newPath); + + const group = this.lazyArchiveInodes.get(key); + if (group) { + const source = Array.from(group.entries.values()).find( + (entry) => + entry.ino === sourceIdentity.ino && + entry.generation === sourceIdentity.generation, + ); + if (source) group.entries.set(newPath, { ...source }); + } } symlink(target: string, path: string): void { @@ -1154,7 +1788,12 @@ export class MemoryFileSystem implements FileSystemBackend { this.chmod(path, mode); } - symlinkWithOwner(target: string, path: string, uid: number, gid: number): void { + symlinkWithOwner( + target: string, + path: string, + uid: number, + gid: number, + ): void { this.symlink(target, path); this.lchown(path, uid, gid); } @@ -1164,6 +1803,7 @@ export class MemoryFileSystem implements FileSystemBackend { target: MemoryFileSystem, lazyFilePaths: Set, lazyArchiveStubPaths: Set, + hardLinks: Map, ): void { const st = this.lstat(path); const kind = st.mode & S_IFMT; @@ -1188,6 +1828,7 @@ export class MemoryFileSystem implements FileSystemBackend { target, lazyFilePaths, lazyArchiveStubPaths, + hardLinks, ); } } finally { @@ -1197,8 +1838,16 @@ export class MemoryFileSystem implements FileSystemBackend { return; } + const identity = st.nlink > 1 ? `${st.dev}:${st.ino}` : null; + const existingHardLink = identity ? hardLinks.get(identity) : undefined; + if (existingHardLink) { + target.link(existingHardLink, path); + return; + } + if (kind === S_IFLNK) { target.symlinkWithOwner(this.readlink(path), path, st.uid, st.gid); + if (identity) hardLinks.set(identity, path); return; } @@ -1206,14 +1855,17 @@ export class MemoryFileSystem implements FileSystemBackend { throw new Error(`Unsupported file type while rebasing VFS: ${path}`); } - const isLazyStub = lazyFilePaths.has(path) || lazyArchiveStubPaths.has(path); + const isLazyStub = + lazyFilePaths.has(path) || lazyArchiveStubPaths.has(path); if (isLazyStub) { target.createFileWithOwner(path, mode, st.uid, st.gid, new Uint8Array(0)); MemoryFileSystem.applyTimes(target, path, st); + if (identity) hardLinks.set(identity, path); return; } this.copyRegularFileToFreshFileSystem(path, target, st, mode); + if (identity) hardLinks.set(identity, path); } private copyRegularFileToFreshFileSystem( @@ -1226,7 +1878,9 @@ export class MemoryFileSystem implements FileSystemBackend { let outFd: number | null = null; try { outFd = target.open(path, O_WRONLY_CREAT_TRUNC, mode); - const chunk = new Uint8Array(Math.min(COPY_CHUNK_BYTES, Math.max(1, st.size))); + const chunk = new Uint8Array( + Math.min(COPY_CHUNK_BYTES, Math.max(1, st.size)), + ); let remaining = st.size; while (remaining > 0) { const wanted = Math.min(chunk.byteLength, remaining); @@ -1258,7 +1912,11 @@ export class MemoryFileSystem implements FileSystemBackend { MemoryFileSystem.applyTimes(target, path, st); } - private static applyTimes(fs: MemoryFileSystem, path: string, st: StatResult): void { + private static applyTimes( + fs: MemoryFileSystem, + path: string, + st: StatResult, + ): void { const atimeSec = Math.floor(st.atimeMs / 1000); const atimeNsec = Math.floor((st.atimeMs - atimeSec * 1000) * 1_000_000); const mtimeSec = Math.floor(st.mtimeMs / 1000); @@ -1271,7 +1929,13 @@ export class MemoryFileSystem implements FileSystemBackend { this.fs.stat(path); } - utimensat(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void { + utimensat( + path: string, + atimeSec: number, + atimeNsec: number, + mtimeSec: number, + mtimeNsec: number, + ): void { this.fs.utimens(path, atimeSec, atimeNsec, mtimeSec, mtimeNsec); } @@ -1285,8 +1949,10 @@ export class MemoryFileSystem implements FileSystemBackend { // Determine d_type from mode const mode = entry.stat.mode; let dtype = 0; // DT_UNKNOWN - if ((mode & 0xf000) === 0x8000) dtype = 8; // DT_REG - else if ((mode & 0xf000) === 0x4000) dtype = 4; // DT_DIR + if ((mode & 0xf000) === 0x8000) + dtype = 8; // DT_REG + else if ((mode & 0xf000) === 0x4000) + dtype = 4; // DT_DIR else if ((mode & 0xf000) === 0xa000) dtype = 10; // DT_LNK return { name: entry.name, type: dtype, ino: entry.stat.ino }; } 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..67d98c963a 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(); @@ -592,9 +644,11 @@ async function handleStat(isLstat: boolean): Promise { // Try file const fileHandle = await dir.getFileHandle(name); - const syncHandle = await fileHandle.createSyncAccessHandle(); - const size = syncHandle.getSize(); - syncHandle.close(); + // A file may already have a live synchronous access handle. OPFS permits + // only one such handle per file, so stat must use the snapshot API rather + // than attempting to acquire a second handle just to read the size. + const file = await fileHandle.getFile(); + const size = file.size; const now = Date.now(); channel.writeStatResult({ @@ -881,11 +935,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..32030f86ac 100644 --- a/host/src/vfs/opfs.ts +++ b/host/src/vfs/opfs.ts @@ -5,7 +5,8 @@ * then block with Atomics.wait() until the OpfsProxyWorker completes * the async OPFS operation. */ -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; +import { filesystemPathconf } from "../pathconf"; import type { FileSystemBackend, DirEntry } from "./types"; import { OpfsChannel, OpfsChannelStatus, OpfsOpcode } from "./opfs-channel"; @@ -25,11 +26,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 +47,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 +83,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 +101,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 +114,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 { @@ -116,10 +131,17 @@ export class OpfsFileSystem implements FileSystemBackend { return this.channel.readStatResult(); } + fpathconf(handle: number, name: number): PathconfValue { + const stat = this.fstat(handle); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + 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); } @@ -160,6 +182,14 @@ export class OpfsFileSystem implements FileSystemBackend { return this.channel.readStatfsResult(); } + pathconf(path: string, name: number): PathconfValue { + const stat = this.stat(path); + return filesystemPathconf(stat, name, { + supportsSymlinks: false, + timestampResolutionNs: null, + }); + } + mkdir(path: string, mode: number): void { this.channel.setArg(0, mode); const pathLen = this.channel.writeString(path); @@ -205,6 +235,11 @@ export class OpfsFileSystem implements FileSystemBackend { // OPFS has no ownership model } + lchown(_path: string, _uid: number, _gid: number): void { + // OPFS has neither symlinks nor an ownership model, so this has the same + // existing no-op boundary as chown. + } + access(path: string, mode: number): void { this.channel.setArg(0, mode); const pathLen = this.channel.writeString(path); diff --git a/host/src/vfs/sharedfs-vendor.ts b/host/src/vfs/sharedfs-vendor.ts index db96aca7ac..8ad1d9d66e 100644 --- a/host/src/vfs/sharedfs-vendor.ts +++ b/host/src/vfs/sharedfs-vendor.ts @@ -47,6 +47,7 @@ export const O_RDONLY = 0x0000; export const O_WRONLY = 0x0001; export const O_RDWR = 0x0002; export const O_CREAT = 0x0040; +export const O_EXCL = 0x0080; export const O_TRUNC = 0x0200; export const O_APPEND = 0x0400; export const O_DIRECTORY = 0x010000; @@ -65,15 +66,18 @@ export const EPERM = -1; export const ENOENT = -2; export const EIO = -5; export const EBADF = -9; +export const EBUSY = -16; export const EEXIST = -17; export const ENOTDIR = -20; export const EISDIR = -21; export const EINVAL = -22; export const EMFILE = -24; +export const EFBIG = -27; 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; @@ -92,6 +96,7 @@ const SB_BLOCK_BITMAP_BLOCKS = 48; const SB_INODE_TABLE_BLOCKS = 52; const SB_GENERATION = 56; const SB_GLOBAL_LOCK = 60; +const SB_NAMESPACE_LOCK = 64; const SB_MAX_SIZE_BLOCKS = 68; const SB_GROW_CHUNK_BLOCKS = 72; @@ -108,7 +113,11 @@ const INO_INDIRECT = 88; const INO_DOUBLE_INDIRECT = 92; const INO_UID = 96; // u32 const INO_GID = 100; // u32 -// 104-127 reserved for future fields (flags, xattrs, etc.) +const INO_GENERATION = 104; // uint64, incremented when an inode slot is allocated +const INO_OPEN_COUNT = 112; // u32, open fd references +const INO_DIR_SEQUENCE = 116; // u32, incremented after every directory mutation +const INO_DATA_SEQUENCE = 120; // u32, incremented after explicit data mutation +// 124-127 reserved for future fields (flags, xattrs, etc.) // FD entry layout const FD_INO = 4; @@ -119,11 +128,16 @@ const FD_IS_DIR = 20; // Lock bits const WRITER_BIT = 0x80000000 | 0; // -2147483648 as int32 const READER_MASK = 0x7fffffff | 0; +const MAX_FILE_BLOCKS = + DIRECT_BLOCKS + PTRS_PER_BLOCK + PTRS_PER_BLOCK * PTRS_PER_BLOCK; +const MAX_FILE_SIZE = MAX_FILE_BLOCKS * BLOCK_SIZE; // ── Types ──────────────────────────────────────────────────────────── export interface StatResult { ino: number; + generation: number; + dataSequence: number; mode: number; linkCount: number; size: number; @@ -143,19 +157,56 @@ export interface SharedFsStats { maxName: number; } +export interface SharedFsIdentityState { + ino: number; + generation: number; + dataSequence: number; + paths: string[]; +} + +export interface NamespaceEntryIdentity { + ino: number; + generation: number; + linkCount: number; + mode: number; +} + +export interface RenameIdentityResult { + source: NamespaceEntryIdentity; + replaced?: NamespaceEntryIdentity; +} + +interface DirIndexEntry { + ino: number; + abs: number; + recLen: number; + nameLen: number; +} + +interface DirIndex { + generation: number; + mutationSequence: number; + size: number; + entries: Map; + free: Array<{ abs: number; recLen: number }>; +} + const ERROR_MESSAGES: Record = { [ENOENT]: "No such file or directory", [EIO]: "I/O error", [EBADF]: "Bad file descriptor", + [EBUSY]: "Device or resource busy", [EEXIST]: "File exists", [ENOTDIR]: "Not a directory", [EISDIR]: "Is a directory", [EINVAL]: "Invalid argument", [EMFILE]: "Too many open files", + [EFBIG]: "File too large", [ENOSPC]: "No space left on device", [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 { @@ -172,6 +223,11 @@ export class SFSError extends Error { const encoder = new TextEncoder(); const decoder = new TextDecoder(); +const DOTDOT_BYTES = encoder.encode(".."); + +function isReservedDirectoryName(name: string): boolean { + return name === "." || name === ".."; +} /** * Safely decode a Uint8Array that may be backed by SharedArrayBuffer. @@ -194,6 +250,19 @@ export class SharedFS { private view: DataView; private i32: Int32Array; private u8: Uint8Array; + private dirIndexes = new Map(); + private blockAllocHint = 0; + private inodeAllocHint = 2; + private atomicsWaitAllowed: boolean | undefined; + + /** + * Directory operations are stored in ext2-style variable-length entries. + * Workloads such as PHP's bug36365 test create tens of thousands of files in + * one directory. Once a directory reaches this threshold, retain validated + * entry locations so each repeated exact-name lookup does not rescan every + * preceding variable-length record. + */ + private static readonly DIR_INDEX_MIN_SIZE = 64 * 1024; private constructor(public readonly buffer: SharedArrayBuffer) { this.view = new DataView(buffer); @@ -207,7 +276,7 @@ export class SharedFS { const sizeBytes = buffer.byteLength; if (sizeBytes < BLOCK_SIZE * 16) throw new SFSError(EINVAL); - const totalBlocks = Math.floor(sizeBytes / BLOCK_SIZE); + let totalBlocks = Math.floor(sizeBytes / BLOCK_SIZE); const maxBlocks = maxSizeBytes ? Math.floor(maxSizeBytes / BLOCK_SIZE) : totalBlocks * 4; @@ -215,21 +284,34 @@ export class SharedFS { // Size inodes for max capacity so we don't run out after growth let totalInodes = Math.floor(maxBlocks / 4); if (totalInodes < 32) totalInodes = 32; - totalInodes = - Math.ceil(totalInodes / INODES_PER_BLOCK) * INODES_PER_BLOCK; + totalInodes = Math.ceil(totalInodes / INODES_PER_BLOCK) * INODES_PER_BLOCK; const inodeBitmapBlocks = Math.ceil(totalInodes / (BLOCK_SIZE * 8)); const blockBitmapBlocks = Math.ceil(maxBlocks / (BLOCK_SIZE * 8)); - const inodeTableBlocks = Math.ceil( - (totalInodes * INODE_SIZE) / BLOCK_SIZE, - ); + const inodeTableBlocks = Math.ceil((totalInodes * INODE_SIZE) / BLOCK_SIZE); const inodeBitmapStart = 1; const blockBitmapStart = inodeBitmapStart + inodeBitmapBlocks; const inodeTableStart = blockBitmapStart + blockBitmapBlocks; const dataStart = inodeTableStart + inodeTableBlocks; - if (dataStart >= totalBlocks) throw new SFSError(ENOSPC); + if (dataStart >= totalBlocks) { + // A growable filesystem sizes its inode and block bitmaps for the + // configured maximum, not just the current buffer length. Large maxima + // can therefore require more metadata blocks than fit in a deliberately + // small initial buffer. Grow enough to format the metadata plus the root + // directory; ordinary data allocation remains lazy after mkfs. + const minimumBytes = (dataStart + 1) * BLOCK_SIZE; + try { + (buffer as SharedArrayBuffer & { grow(size: number): void }).grow( + minimumBytes, + ); + } catch { + throw new SFSError(ENOSPC); + } + totalBlocks = Math.floor(buffer.byteLength / BLOCK_SIZE); + if (dataStart >= totalBlocks) throw new SFSError(ENOSPC); + } // Zero the buffer new Uint8Array(buffer).fill(0); @@ -261,16 +343,19 @@ export class SharedFS { const freeDataBlocks = totalBlocks - dataStart; Atomics.store(fs.i32, SB_FREE_BLOCKS >> 2, freeDataBlocks); + fs.blockAllocHint = dataStart; // Mark inodes 0 and 1 as used const ibStart = inodeBitmapStart * BLOCK_SIZE; fs.i32[ibStart >> 2] |= 0x3; Atomics.store(fs.i32, SB_FREE_INODES >> 2, totalInodes - 2); + fs.inodeAllocHint = 2; // Initialize root inode (inode 1) as empty directory const rootOff = fs.inodeOffset(ROOT_INO); fs.w32(rootOff + INO_MODE, S_IFDIR | 0o755); fs.w32(rootOff + INO_LINK_COUNT, 2); + fs.w64(rootOff + INO_GENERATION, 1); // Allocate a data block for root's directory entries const rootBlock = fs.blockAlloc(); @@ -303,16 +388,165 @@ export class SharedFS { return fs; } - static mount(buffer: SharedArrayBuffer): SharedFS { + static mount( + buffer: SharedArrayBuffer, + options?: { restoreImage?: boolean }, + ): SharedFS { const fs = new SharedFS(buffer); if (fs.r32(SB_MAGIC) !== MAGIC) throw new SFSError(EINVAL, "Bad magic"); if (fs.r32(SB_VERSION) !== VERSION) throw new SFSError(EINVAL, "Bad version"); if (fs.r32(SB_BLOCK_SIZE) !== BLOCK_SIZE) throw new SFSError(EINVAL, "Bad block size"); + if (options?.restoreImage) fs.resetRestoredRuntimeState(); + fs.resetAllocationHints(); return fs; } + /** + * Return a portable, quiescent copy of the filesystem bytes. + * + * File descriptors and inode locks are process-runtime state, not VFS image + * state. Refuse to snapshot while descriptors are live, then clear all lock + * words in the copy so a restored image cannot inherit a dead worker's lock. + */ + snapshotBytes(): Uint8Array { + return this.withNamespaceLock(() => this.snapshotBytesUnlocked()); + } + + snapshotState(): { + bytes: Uint8Array; + identities: Map; + } { + return this.withNamespaceLock(() => { + // Validate quiescence and copy first. With the namespace lock held, no + // new descriptor can appear while the matching path identities are + // collected for lazy metadata. + const bytes = this.snapshotBytesUnlocked(); + return { bytes, identities: this.collectIdentityStateUnlocked() }; + }); + } + + identityState(): Map { + return this.withNamespaceLock(() => this.collectIdentityStateUnlocked()); + } + + private snapshotBytesUnlocked(): Uint8Array { + for (let fd = 0; fd < MAX_FDS; fd++) { + const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; + if (Atomics.load(this.i32, base >> 2) !== 0) { + throw new SFSError( + EBUSY, + "Cannot save a VFS image with open descriptors", + ); + } + } + + const totalInodes = this.r32(SB_TOTAL_INODES); + for (let ino = 0; ino < totalInodes; ino++) { + const off = this.inodeOffset(ino); + if (this.r32(off + INO_OPEN_COUNT) !== 0) { + throw new SFSError( + EBUSY, + "Cannot save a VFS image with open inode references", + ); + } + } + + const copy = new Uint8Array(this.buffer.byteLength); + copy.set(this.u8); + const view = new DataView(copy.buffer); + view.setUint32(SB_GLOBAL_LOCK, 0, true); + view.setUint32(SB_NAMESPACE_LOCK, 0, true); + copy.fill(0, FD_TABLE_OFFSET, BLOCK_SIZE); + + for (let ino = 0; ino < totalInodes; ino++) { + const off = this.inodeOffset(ino); + view.setUint32(off + INO_LOCK_STATE, 0, true); + view.setUint32(off + INO_OPEN_COUNT, 0, true); + } + return copy; + } + + private collectIdentityStateUnlocked(): Map { + const identities = new Map(); + const directories: Array<{ ino: number; path: string }> = [ + { ino: ROOT_INO, path: "/" }, + ]; + const visitedDirectories = new Set(); + + while (directories.length > 0) { + const directory = directories.pop()!; + if (visitedDirectories.has(directory.ino)) throw new SFSError(EIO); + visitedDirectories.add(directory.ino); + + const inoOff = this.inodeOffset(directory.ino); + if ((this.r32(inoOff + INO_MODE) & S_IFMT) !== S_IFDIR) { + throw new SFSError(EIO); + } + const dirSize = this.r64(inoOff + INO_SIZE); + let pos = 0; + while (pos < dirSize) { + const fileBlock = Math.floor(pos / BLOCK_SIZE); + const blockOff = pos % BLOCK_SIZE; + const phys = this.inodeBlockMap(directory.ino, fileBlock, false); + if (phys <= 0) throw new SFSError(EIO); + const blockBase = phys * BLOCK_SIZE; + const remain = Math.min(dirSize - pos, BLOCK_SIZE - blockOff); + + let off = blockOff; + while (off < blockOff + remain) { + const abs = blockBase + off; + const entIno = this.r32(abs); + const recLen = this.view.getUint16(abs + 4, true); + const nameLen = this.view.getUint16(abs + 6, true); + if (!this.isValidDirEntry(off, blockOff + remain, recLen, nameLen)) { + throw new SFSError(EIO); + } + if (entIno !== 0) { + if (!this.inodeIsAllocated(entIno)) throw new SFSError(EIO); + const name = safeDecode( + this.u8.subarray( + abs + DIRENT_HEADER_SIZE, + abs + DIRENT_HEADER_SIZE + nameLen, + ), + ); + if (name !== "." && name !== "..") { + const childPath = + directory.path === "/" + ? `/${name}` + : `${directory.path}/${name}`; + const childOff = this.inodeOffset(entIno); + const generation = this.r64(childOff + INO_GENERATION); + const key = `${entIno}:${generation}`; + let identity = identities.get(key); + if (!identity) { + identity = { + ino: entIno, + generation, + dataSequence: + Atomics.load( + this.i32, + (childOff + INO_DATA_SEQUENCE) >> 2, + ) >>> 0, + paths: [], + }; + identities.set(key, identity); + } + identity.paths.push(childPath); + if ((this.r32(childOff + INO_MODE) & S_IFMT) === S_IFDIR) { + directories.push({ ino: entIno, path: childPath }); + } + } + } + off += recLen; + } + pos += remain; + } + } + return identities; + } + statfs(): SharedFsStats { const blockSize = this.r32(SB_BLOCK_SIZE); const currentBlocks = this.r32(SB_TOTAL_BLOCKS); @@ -354,6 +588,60 @@ export class SharedFS { this.view.setBigUint64(off, BigInt(v), true); } + /** + * Wait for a shared lock word to change. + * + * Browser main threads forbid Atomics.wait(). The legacy shared-filesystem + * BrowserKernel path still exposes synchronous MemoryFileSystem methods on + * that thread, so fall back to atomic polling while the independently + * scheduled kernel worker finishes its short critical section. Preferred + * kernel-owned browser boots never exercise this fallback. + */ + private waitForAtomicChange(index: number, expected: number): void { + if (this.atomicsWaitAllowed !== false) { + try { + Atomics.wait(this.i32, index, expected); + this.atomicsWaitAllowed = true; + return; + } catch (error) { + if (!(error instanceof TypeError)) throw error; + this.atomicsWaitAllowed = false; + } + } + while (Atomics.load(this.i32, index) === expected) { + // Synchronous SharedFS cannot yield a Promise here. The lock owner is a + // different worker, so atomic polling does not prevent its progress. + } + } + + private resetAllocationHints(): void { + this.blockAllocHint = this.findNextFreeBlockHint(); + this.inodeAllocHint = this.findNextFreeInodeHint(); + } + + private findNextFreeBlockHint(): number { + const totalBlocks = this.r32(SB_TOTAL_BLOCKS); + const dataStart = this.r32(SB_DATA_START); + const bbStart = this.r32(SB_BLOCK_BITMAP_START) * BLOCK_SIZE; + for (let blockNo = dataStart; blockNo < totalBlocks; blockNo++) { + const idx = (bbStart >> 2) + (blockNo >> 5); + const bit = blockNo & 31; + if ((Atomics.load(this.i32, idx) & (1 << bit)) === 0) return blockNo; + } + return dataStart; + } + + private findNextFreeInodeHint(): number { + const totalInodes = this.r32(SB_TOTAL_INODES); + const ibStart = this.r32(SB_INODE_BITMAP_START) * BLOCK_SIZE; + for (let ino = 2; ino < totalInodes; ino++) { + const idx = (ibStart >> 2) + (ino >> 5); + const bit = ino & 31; + if ((Atomics.load(this.i32, idx) & (1 << bit)) === 0) return ino; + } + return 2; + } + // ── Superblock lock (for grow) ─────────────────────────────────── private sbLock(): void { @@ -361,7 +649,7 @@ export class SharedFS { for (;;) { const old = Atomics.compareExchange(this.i32, idx, 0, 1); if (old === 0) return; - Atomics.wait(this.i32, idx, 1); + this.waitForAtomicChange(idx, 1); } } @@ -371,36 +659,100 @@ export class SharedFS { Atomics.notify(this.i32, idx, Infinity); } + // ── Namespace lock (path resolution and mutation) ─────────────── + + private namespaceLock(): void { + const idx = SB_NAMESPACE_LOCK >> 2; + for (;;) { + const old = Atomics.compareExchange(this.i32, idx, 0, 1); + if (old === 0) return; + this.waitForAtomicChange(idx, 1); + } + } + + private namespaceUnlock(): void { + const idx = SB_NAMESPACE_LOCK >> 2; + Atomics.store(this.i32, idx, 0); + Atomics.notify(this.i32, idx, Infinity); + } + + private withNamespaceLock(operation: () => T): T { + this.namespaceLock(); + try { + return operation(); + } finally { + this.namespaceUnlock(); + } + } + + /** Reset process-local runtime state after copying a portable image. */ + private resetRestoredRuntimeState(): void { + // The buffer is private to fromImage(), so stale locks can be cleared + // directly before any lock-taking operation is attempted. + Atomics.store(this.i32, SB_GLOBAL_LOCK >> 2, 0); + Atomics.store(this.i32, SB_NAMESPACE_LOCK >> 2, 0); + this.u8.fill(0, FD_TABLE_OFFSET, BLOCK_SIZE); + + const totalInodes = this.r32(SB_TOTAL_INODES); + const ibStart = this.r32(SB_INODE_BITMAP_START) * BLOCK_SIZE; + for (let ino = 0; ino < totalInodes; ino++) { + const off = this.inodeOffset(ino); + this.w32(off + INO_LOCK_STATE, 0); + this.w32(off + INO_OPEN_COUNT, 0); + if (ino < 2) continue; + const word = this.r32(ibStart + (ino >> 5) * 4); + if ((word & (1 << (ino & 31))) === 0) continue; + if (this.r32(off + INO_LINK_COUNT) !== 0) continue; + + const mode = this.r32(off + INO_MODE); + const size = this.r64(off + INO_SIZE); + if ((mode & S_IFMT) === S_IFLNK && size <= INLINE_SYMLINK_SIZE) { + this.u8.fill( + 0, + off + INO_DIRECT, + off + INO_DIRECT + INLINE_SYMLINK_SIZE, + ); + this.w64(off + INO_SIZE, 0); + } else { + this.inodeTruncate(ino, 0); + } + this.inodeFree(ino); + } + } + // ── Block allocator ────────────────────────────────────────────── private blockAlloc(): number { const totalBlocks = this.r32(SB_TOTAL_BLOCKS); const bbStart = this.r32(SB_BLOCK_BITMAP_START) * BLOCK_SIZE; - const numWords = Math.ceil(totalBlocks / 32); - - for (let w = 0; w < numWords; w++) { - const idx = (bbStart >> 2) + w; + const dataStart = this.r32(SB_DATA_START); + const start = + this.blockAllocHint >= dataStart && this.blockAllocHint < totalBlocks + ? this.blockAllocHint + : dataStart; + const allocatableBlocks = totalBlocks - dataStart; + + for (let checked = 0; checked < allocatableBlocks; checked++) { + const blockNo = + dataStart + ((start - dataStart + checked) % allocatableBlocks); + const idx = (bbStart >> 2) + (blockNo >> 5); + const bit = blockNo & 31; const word = Atomics.load(this.i32, idx); - if (word === -1) continue; // all bits set (0xFFFFFFFF as int32) - - for (let bit = 0; bit < 32; bit++) { - const blockNo = w * 32 + bit; - if (blockNo >= totalBlocks) return ENOSPC; - if (word & (1 << bit)) continue; - - const desired = word | (1 << bit); - const old = Atomics.compareExchange(this.i32, idx, word, desired); - if (old === word) { - Atomics.sub(this.i32, SB_FREE_BLOCKS >> 2, 1); - // Zero the newly allocated block - const off = blockNo * BLOCK_SIZE; - this.u8.fill(0, off, off + BLOCK_SIZE); - return blockNo; - } - // CAS failed — retry this word - w--; - break; + if (word & (1 << bit)) continue; + + const desired = word | (1 << bit); + const old = Atomics.compareExchange(this.i32, idx, word, desired); + if (old === word) { + Atomics.sub(this.i32, SB_FREE_BLOCKS >> 2, 1); + this.blockAllocHint = + blockNo + 1 < totalBlocks ? blockNo + 1 : dataStart; + // Zero the newly allocated block + const off = blockNo * BLOCK_SIZE; + this.u8.fill(0, off, off + BLOCK_SIZE); + return blockNo; } + // CAS failed — retry this candidate. + checked--; } return ENOSPC; } @@ -426,6 +778,9 @@ export class SharedFS { if (old === word) break; } Atomics.add(this.i32, SB_FREE_BLOCKS >> 2, 1); + if (blockNo >= this.r32(SB_DATA_START) && blockNo < this.blockAllocHint) { + this.blockAllocHint = blockNo; + } } // ── Growth ─────────────────────────────────────────────────────── @@ -462,6 +817,7 @@ export class SharedFS { this.w32(SB_TOTAL_BLOCKS, newTotal); Atomics.add(this.i32, SB_FREE_BLOCKS >> 2, growBy); Atomics.add(this.i32, SB_GENERATION >> 2, 1); + this.blockAllocHint = current; return 0; } finally { this.sbUnlock(); @@ -480,34 +836,40 @@ export class SharedFS { private inodeAlloc(): number { const totalInodes = this.r32(SB_TOTAL_INODES); const ibStart = this.r32(SB_INODE_BITMAP_START) * BLOCK_SIZE; - const numWords = Math.ceil(totalInodes / 32); - - for (let w = 0; w < numWords; w++) { - const idx = (ibStart >> 2) + w; + const start = + this.inodeAllocHint >= 2 && this.inodeAllocHint < totalInodes + ? this.inodeAllocHint + : 2; + const allocatableInodes = totalInodes - 2; + + for (let checked = 0; checked < allocatableInodes; checked++) { + const ino = 2 + ((start - 2 + checked) % allocatableInodes); + const idx = (ibStart >> 2) + (ino >> 5); + const bit = ino & 31; const word = Atomics.load(this.i32, idx); - if (word === -1) continue; - - for (let bit = 0; bit < 32; bit++) { - const ino = w * 32 + bit; - if (ino >= totalInodes) return ENOSPC; - if (word & (1 << bit)) continue; - - const desired = word | (1 << bit); - const old = Atomics.compareExchange(this.i32, idx, word, desired); - if (old === word) { - Atomics.sub(this.i32, SB_FREE_INODES >> 2, 1); - // Zero the inode - const off = this.inodeOffset(ino); - this.u8.fill(0, off, off + INODE_SIZE); - return ino; - } - w--; - break; + if (word & (1 << bit)) continue; + + const desired = word | (1 << bit); + const old = Atomics.compareExchange(this.i32, idx, word, desired); + if (old === word) { + Atomics.sub(this.i32, SB_FREE_INODES >> 2, 1); + this.inodeAllocHint = ino + 1 < totalInodes ? ino + 1 : 2; + // Zero the inode + const off = this.inodeOffset(ino); + this.u8.fill(0, off, off + INODE_SIZE); + this.w64(off + INO_GENERATION, this.nextInodeGeneration()); + return ino; } + // CAS failed — retry this candidate. + checked--; } return ENOSPC; } + private nextInodeGeneration(): number { + return Atomics.add(this.i32, SB_GENERATION >> 2, 1) + 1; + } + private inodeFree(ino: number): void { const ibStart = this.r32(SB_INODE_BITMAP_START) * BLOCK_SIZE; const idx = (ibStart >> 2) + (ino >> 5); @@ -515,11 +877,74 @@ export class SharedFS { for (;;) { const word = Atomics.load(this.i32, idx); + if ((word & (1 << bit)) === 0) throw new SFSError(EIO); const desired = word & ~(1 << bit); const old = Atomics.compareExchange(this.i32, idx, word, desired); if (old === word) break; } Atomics.add(this.i32, SB_FREE_INODES >> 2, 1); + if (ino >= 2 && ino < this.inodeAllocHint) this.inodeAllocHint = ino; + } + + private inodeAddOpenRef(ino: number): boolean { + this.inodeWriteLock(ino); + try { + const off = this.inodeOffset(ino); + if (this.r32(off + INO_LINK_COUNT) === 0) return false; + this.w32(off + INO_OPEN_COUNT, this.r32(off + INO_OPEN_COUNT) + 1); + return true; + } finally { + this.inodeWriteUnlock(ino); + } + } + + private inodeDropOpenRef(ino: number): void { + let shouldFree = false; + this.inodeWriteLock(ino); + try { + const off = this.inodeOffset(ino); + const openCount = this.r32(off + INO_OPEN_COUNT); + if (openCount > 0) { + this.w32(off + INO_OPEN_COUNT, openCount - 1); + } + if (openCount <= 1 && this.r32(off + INO_LINK_COUNT) === 0) { + this.inodeTruncate(ino, 0); + shouldFree = true; + } + } finally { + this.inodeWriteUnlock(ino); + } + if (shouldFree) this.inodeFree(ino); + } + + private inodeDropLinkRefLocked(ino: number): boolean { + const off = this.inodeOffset(ino); + const linkCount = this.r32(off + INO_LINK_COUNT); + if (linkCount > 1) { + this.w32(off + INO_LINK_COUNT, linkCount - 1); + this.w64(off + INO_CTIME, Date.now()); + return false; + } + return this.inodeOrphanLocked(ino); + } + + private inodeOrphanLocked(ino: number): boolean { + const off = this.inodeOffset(ino); + this.w32(off + INO_LINK_COUNT, 0); + this.w64(off + INO_CTIME, Date.now()); + if (this.r32(off + INO_OPEN_COUNT) > 0) return false; + const mode = this.r32(off + INO_MODE); + const size = this.r64(off + INO_SIZE); + if ((mode & S_IFMT) === S_IFLNK && size <= INLINE_SYMLINK_SIZE) { + // Short symlink targets are stored inline in the inode's direct-pointer + // area. POSIX unlink removes the symlink inode itself even if the target + // is dangling; do not interpret inline target bytes as block numbers. + this.u8.fill(0, off + INO_DIRECT, off + INO_DIRECT + INLINE_SYMLINK_SIZE); + this.w64(off + INO_SIZE, 0); + } else { + this.inodeTruncate(ino, 0); + } + return true; } // ── Inode locking ──────────────────────────────────────────────── @@ -529,15 +954,10 @@ export class SharedFS { for (;;) { const cur = Atomics.load(this.i32, lockIdx); if (cur & WRITER_BIT) { - Atomics.wait(this.i32, lockIdx, cur); + this.waitForAtomicChange(lockIdx, cur); continue; } - const old = Atomics.compareExchange( - this.i32, - lockIdx, - cur, - cur + 1, - ); + const old = Atomics.compareExchange(this.i32, lockIdx, cur, cur + 1); if (old === cur) return; } } @@ -555,15 +975,10 @@ export class SharedFS { for (;;) { const cur = Atomics.load(this.i32, lockIdx); if (cur !== 0) { - Atomics.wait(this.i32, lockIdx, cur); + this.waitForAtomicChange(lockIdx, cur); continue; } - const old = Atomics.compareExchange( - this.i32, - lockIdx, - 0, - WRITER_BIT, - ); + const old = Atomics.compareExchange(this.i32, lockIdx, 0, WRITER_BIT); if (old === 0) return; } } @@ -598,18 +1013,26 @@ export class SharedFS { fileBlock -= DIRECT_BLOCKS; if (fileBlock < PTRS_PER_BLOCK) { let ind = this.r32(inoOff + INO_INDIRECT); + let allocatedIndirect = false; if (ind === 0) { if (!allocate) return 0; ind = this.blockAllocWithGrow(); if (ind < 0) return ind; this.w32(inoOff + INO_INDIRECT, ind); + allocatedIndirect = true; } const ptrOff = ind * BLOCK_SIZE + fileBlock * 4; const ptr = this.r32(ptrOff); if (ptr !== 0) return ptr; if (!allocate) return 0; const blk = this.blockAllocWithGrow(); - if (blk < 0) return blk; + if (blk < 0) { + if (allocatedIndirect) { + this.w32(inoOff + INO_INDIRECT, 0); + this.blockFree(ind); + } + return blk; + } this.w32(ptrOff, blk); return blk; } @@ -621,20 +1044,30 @@ export class SharedFS { const idx2 = fileBlock % PTRS_PER_BLOCK; let dind = this.r32(inoOff + INO_DOUBLE_INDIRECT); + let allocatedDoubleIndirect = false; if (dind === 0) { if (!allocate) return 0; dind = this.blockAllocWithGrow(); if (dind < 0) return dind; this.w32(inoOff + INO_DOUBLE_INDIRECT, dind); + allocatedDoubleIndirect = true; } const l1Off = dind * BLOCK_SIZE + idx1 * 4; let l1 = this.r32(l1Off); + let allocatedFirstLevel = false; if (l1 === 0) { if (!allocate) return 0; l1 = this.blockAllocWithGrow(); - if (l1 < 0) return l1; + if (l1 < 0) { + if (allocatedDoubleIndirect) { + this.w32(inoOff + INO_DOUBLE_INDIRECT, 0); + this.blockFree(dind); + } + return l1; + } this.w32(l1Off, l1); + allocatedFirstLevel = true; } const l2Off = l1 * BLOCK_SIZE + idx2 * 4; @@ -642,7 +1075,17 @@ export class SharedFS { if (ptr !== 0) return ptr; if (!allocate) return 0; const blk = this.blockAllocWithGrow(); - if (blk < 0) return blk; + if (blk < 0) { + if (allocatedFirstLevel) { + this.w32(l1Off, 0); + this.blockFree(l1); + } + if (allocatedDoubleIndirect) { + this.w32(inoOff + INO_DOUBLE_INDIRECT, 0); + this.blockFree(dind); + } + return blk; + } this.w32(l2Off, blk); return blk; } @@ -695,6 +1138,11 @@ export class SharedFS { count: number, ): number { const inoOff = this.inodeOffset(ino); + const size = this.r64(inoOff + INO_SIZE); + if (offset > size) { + this.zeroOldEofTail(ino, size); + } + let totalWritten = 0; let srcPos = 0; @@ -705,7 +1153,10 @@ export class SharedFS { if (chunk > count) chunk = count; const phys = this.inodeBlockMap(ino, fileBlock, true); - if (phys < 0) return totalWritten > 0 ? totalWritten : phys; + if (phys < 0) { + if (totalWritten === 0) return phys; + break; + } const dstOff = phys * BLOCK_SIZE + blockOff; this.u8.set(src.subarray(srcPos, srcPos + chunk), dstOff); @@ -716,13 +1167,47 @@ export class SharedFS { totalWritten += chunk; } - const size = this.r64(inoOff + INO_SIZE); - if (offset > size) { + if (totalWritten > 0 && offset > this.r64(inoOff + INO_SIZE)) { this.w64(inoOff + INO_SIZE, offset); } + if (totalWritten > 0) { + const now = Date.now(); + this.w64(inoOff + INO_MTIME, now); + this.w64(inoOff + INO_CTIME, now); + Atomics.add(this.i32, (inoOff + INO_DATA_SEQUENCE) >> 2, 1); + } return totalWritten; } + private zeroInodeRange(ino: number, start: number, end: number): void { + while (start < end) { + const fileBlock = Math.floor(start / BLOCK_SIZE); + const blockOff = start % BLOCK_SIZE; + const chunk = Math.min(BLOCK_SIZE - blockOff, end - start); + const phys = this.inodeBlockMap(ino, fileBlock, false); + if (phys > 0) { + const abs = phys * BLOCK_SIZE + blockOff; + this.u8.fill(0, abs, abs + chunk); + } + start += chunk; + } + } + + /** + * Zero only the allocated tail of the old EOF block. Sparse extension does + * not need to walk logical holes: absent blocks already read as zero and a + * newly allocated block is cleared by blockAlloc(). + */ + private zeroOldEofTail(ino: number, oldSize: number): void { + const blockOff = oldSize % BLOCK_SIZE; + if (blockOff === 0) return; + const fileBlock = Math.floor(oldSize / BLOCK_SIZE); + const phys = this.inodeBlockMap(ino, fileBlock, false); + if (phys <= 0) return; + const start = phys * BLOCK_SIZE + blockOff; + this.u8.fill(0, start, phys * BLOCK_SIZE + BLOCK_SIZE); + } + private freeBlocksFrom(ino: number, fromBlock: number): void { const inoOff = this.inodeOffset(ino); @@ -738,8 +1223,7 @@ export class SharedFS { // Single indirect const ind = this.r32(inoOff + INO_INDIRECT); if (ind) { - const start = - fromBlock > DIRECT_BLOCKS ? fromBlock - DIRECT_BLOCKS : 0; + const start = fromBlock > DIRECT_BLOCKS ? fromBlock - DIRECT_BLOCKS : 0; for (let i = start; i < PTRS_PER_BLOCK; i++) { const ptrOff = ind * BLOCK_SIZE + i * 4; const ptr = this.r32(ptrOff); @@ -789,59 +1273,445 @@ export class SharedFS { } } - private inodeTruncate(ino: number, newSize: number): void { + private inodeTruncate( + ino: number, + newSize: number, + forceDataMutation = false, + ): void { const inoOff = this.inodeOffset(ino); const curSize = this.r64(inoOff + INO_SIZE); + const sizeChanged = newSize !== curSize; if (newSize >= curSize) { + if (newSize > curSize) { + this.zeroOldEofTail(ino, curSize); + } this.w64(inoOff + INO_SIZE, newSize); + if (sizeChanged || forceDataMutation) { + const now = Date.now(); + this.w64(inoOff + INO_MTIME, now); + this.w64(inoOff + INO_CTIME, now); + Atomics.add(this.i32, (inoOff + INO_DATA_SEQUENCE) >> 2, 1); + } return; } + if (newSize % BLOCK_SIZE !== 0) { + this.zeroInodeRange( + ino, + newSize, + Math.ceil(newSize / BLOCK_SIZE) * BLOCK_SIZE, + ); + } const keepBlocks = Math.ceil(newSize / BLOCK_SIZE); this.freeBlocksFrom(ino, keepBlocks); this.w64(inoOff + INO_SIZE, newSize); + if (sizeChanged || forceDataMutation) { + const now = Date.now(); + this.w64(inoOff + INO_MTIME, now); + this.w64(inoOff + INO_CTIME, now); + Atomics.add(this.i32, (inoOff + INO_DATA_SEQUENCE) >> 2, 1); + } + } + + private validateFileSize(size: number): void { + if (!Number.isSafeInteger(size) || size < 0) throw new SFSError(EINVAL); + 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 { + const inoOff = this.inodeOffset(dirIno); + const now = Date.now(); + this.w64(inoOff + INO_MTIME, now); + this.w64(inoOff + INO_CTIME, now); + const mutationSequence = + (Atomics.add(this.i32, (inoOff + INO_DIR_SEQUENCE) >> 2, 1) + 1) >>> 0; + const index = this.dirIndexes.get(dirIno); + if (index) { + index.mutationSequence = mutationSequence; + index.size = this.r64(inoOff + INO_SIZE); + } + } + + private dirNameKey(name: Uint8Array): string { + return safeDecode(name); + } + + private dirEntryNameMatches(abs: number, name: Uint8Array): boolean { + const entNameLen = this.view.getUint16(abs + 6, true); + if (entNameLen !== name.length) return false; + for (let i = 0; i < name.length; i++) { + if (this.u8[abs + DIRENT_HEADER_SIZE + i] !== name[i]) return false; + } + return true; + } + + private isValidDirEntry( + off: number, + endOff: number, + recLen: number, + nameLen: number, + ): boolean { + return ( + recLen >= DIRENT_HEADER_SIZE && + recLen % 4 === 0 && + off + recLen <= endOff && + nameLen <= recLen - DIRENT_HEADER_SIZE + ); + } + + private inodeIsAllocated(ino: number): boolean { + const totalInodes = this.r32(SB_TOTAL_INODES); + if (ino <= 0 || ino >= totalInodes) return false; + const ibStart = this.r32(SB_INODE_BITMAP_START) * BLOCK_SIZE; + const word = Atomics.load(this.i32, (ibStart >> 2) + (ino >> 5)); + return (word & (1 << (ino & 31))) !== 0; + } + + private rebuildDirIndex( + dirIno: number, + generation: number, + mutationSequence: number, + dirSize: number, + ): DirIndex | number { + const entries = new Map(); + const free: Array<{ abs: number; recLen: number }> = []; + let pos = 0; + + while (pos < dirSize) { + const fileBlock = Math.floor(pos / BLOCK_SIZE); + const blockOff = pos % BLOCK_SIZE; + const phys = this.inodeBlockMap(dirIno, fileBlock, false); + if (phys <= 0) return EIO; + + const blockBase = phys * BLOCK_SIZE; + let remain = dirSize - pos; + if (remain > BLOCK_SIZE - blockOff) remain = BLOCK_SIZE - blockOff; + + let off = blockOff; + while (off < blockOff + remain) { + const abs = blockBase + off; + const entIno = this.r32(abs); + const recLen = this.view.getUint16(abs + 4, true); + const entNameLen = this.view.getUint16(abs + 6, true); + + if (!this.isValidDirEntry(off, blockOff + remain, recLen, entNameLen)) + return EIO; + + if (entIno !== 0) { + if (!this.inodeIsAllocated(entIno)) return EIO; + const name = safeDecode( + this.u8.subarray( + abs + DIRENT_HEADER_SIZE, + abs + DIRENT_HEADER_SIZE + entNameLen, + ), + ); + entries.set(name, { + ino: entIno, + abs, + recLen, + nameLen: entNameLen, + }); + } else if (recLen >= DIRENT_HEADER_SIZE) { + free.push({ abs, recLen }); + } + + off += recLen; + } + pos += remain; + } + + const index = { + generation, + mutationSequence, + size: dirSize, + entries, + free, + }; + this.dirIndexes.set(dirIno, index); + return index; + } + + private getDirIndex(dirIno: number): DirIndex | null | number { + const inoOff = this.inodeOffset(dirIno); + const dirSize = this.r64(inoOff + INO_SIZE); + const generation = this.r64(inoOff + INO_GENERATION); + const mutationSequence = + Atomics.load(this.i32, (inoOff + INO_DIR_SEQUENCE) >> 2) >>> 0; + const cached = this.dirIndexes.get(dirIno); + if ( + cached && + cached.generation === generation && + cached.mutationSequence === mutationSequence && + cached.size === dirSize + ) { + return cached; + } + if (cached) this.dirIndexes.delete(dirIno); + + if (dirSize < SharedFS.DIR_INDEX_MIN_SIZE) return null; + return this.rebuildDirIndex(dirIno, generation, mutationSequence, dirSize); + } + + private updateDirIndexAdd( + dirIno: number, + name: Uint8Array, + childIno: number, + abs: number, + recLen: number, + ): void { + const inoOff = this.inodeOffset(dirIno); + const dirSize = this.r64(inoOff + INO_SIZE); + const generation = this.r64(inoOff + INO_GENERATION); + const index = this.dirIndexes.get(dirIno); + if (!index) return; + if (index.generation !== generation) { + this.dirIndexes.delete(dirIno); + return; + } + index.size = dirSize; + index.entries.set(this.dirNameKey(name), { + ino: childIno, + abs, + recLen, + nameLen: name.length, + }); + } + + private useDirIndexFreeSlot( + index: DirIndex, + dirIno: number, + name: Uint8Array, + childIno: number, + ): boolean { + const needed = align4(DIRENT_HEADER_SIZE + name.length); + + for (let i = index.free.length - 1; i >= 0; i--) { + const slot = index.free[i]; + if (slot.recLen < needed) continue; + index.free.splice(i, 1); + if ( + this.r32(slot.abs) !== 0 || + this.view.getUint16(slot.abs + 4, true) !== slot.recLen + ) { + continue; + } + + this.w32(slot.abs, childIno); + this.view.setUint16(slot.abs + 6, name.length, true); + this.u8.set(name, slot.abs + DIRENT_HEADER_SIZE); + this.touchDirectoryMutation(dirIno); + this.updateDirIndexAdd(dirIno, name, childIno, slot.abs, slot.recLen); + return true; + } + + return false; + } + + private updateDirIndexRemove(dirIno: number, name: Uint8Array): void { + const inoOff = this.inodeOffset(dirIno); + const dirSize = this.r64(inoOff + INO_SIZE); + const generation = this.r64(inoOff + INO_GENERATION); + const index = this.dirIndexes.get(dirIno); + if (!index) return; + if (index.generation !== generation || index.size !== dirSize) { + this.dirIndexes.delete(dirIno); + return; + } + index.entries.delete(this.dirNameKey(name)); + } + + private updateDirIndexRecLen( + dirIno: number, + abs: number, + recLen: number, + ): void { + const index = this.dirIndexes.get(dirIno); + if (!index) return; + for (const entry of index.entries.values()) { + if (entry.abs === abs) { + entry.recLen = recLen; + return; + } + } + } + private dirLookup(dirIno: number, name: Uint8Array): number { + const index = this.getDirIndex(dirIno); + if (typeof index === "number") return index; + if (index) { + const entry = index.entries.get(this.dirNameKey(name)); + if (!entry) return ENOENT; + + // Validate positive hits against the backing directory entry so stale + // in-process indexes cannot resurrect an externally removed name. + if ( + this.r32(entry.abs) === entry.ino && + this.inodeIsAllocated(entry.ino) && + this.view.getUint16(entry.abs + 4, true) === entry.recLen && + this.view.getUint16(entry.abs + 6, true) === entry.nameLen && + this.dirEntryNameMatches(entry.abs, name) + ) { + return entry.ino; + } + + index.entries.delete(this.dirNameKey(name)); + return ENOENT; + } + const inoOff = this.inodeOffset(dirIno); const dirSize = this.r64(inoOff + INO_SIZE); let pos = 0; - while (pos < dirSize) { - const fileBlock = Math.floor(pos / BLOCK_SIZE); - const blockOff = pos % BLOCK_SIZE; - const phys = this.inodeBlockMap(dirIno, fileBlock, false); + while (pos < dirSize) { + const fileBlock = Math.floor(pos / BLOCK_SIZE); + const blockOff = pos % BLOCK_SIZE; + const phys = this.inodeBlockMap(dirIno, fileBlock, false); + if (phys <= 0) return EIO; + + const blockBase = phys * BLOCK_SIZE; + let remain = dirSize - pos; + if (remain > BLOCK_SIZE - blockOff) remain = BLOCK_SIZE - blockOff; + + let off = blockOff; + while (off < blockOff + remain) { + const abs = blockBase + off; + const entIno = this.r32(abs); + const recLen = this.view.getUint16(abs + 4, true); + const entNameLen = this.view.getUint16(abs + 6, true); + + if (!this.isValidDirEntry(off, blockOff + remain, recLen, entNameLen)) + return EIO; + + if (entIno !== 0 && entNameLen === name.length) { + let match = true; + for (let i = 0; i < name.length; i++) { + if (this.u8[abs + DIRENT_HEADER_SIZE + i] !== name[i]) { + match = false; + break; + } + } + if (match) { + if (!this.inodeIsAllocated(entIno)) return EIO; + return entIno; + } + } + off += recLen; + } + pos += remain; + } + return ENOENT; + } + + private findLastDirEntryInBlock( + dirIno: number, + fileBlock: number, + endOff: number, + ): number { + const phys = this.inodeBlockMap(dirIno, fileBlock, false); + if (phys <= 0) return -1; + const blockBase = phys * BLOCK_SIZE; + let off = 0; + let lastAbs = -1; + while (off < endOff) { + const abs = blockBase + off; + const recLen = this.view.getUint16(abs + 4, true); + if ( + recLen < DIRENT_HEADER_SIZE || + recLen % 4 !== 0 || + off + recLen > endOff + ) { + return -1; + } + lastAbs = abs; + off += recLen; + } + return off === endOff ? lastAbs : -1; + } + + private dirAppendEntry( + dirIno: number, + name: Uint8Array, + childIno: number, + lastEntAbs = -1, + ): number { + const inoOff = this.inodeOffset(dirIno); + const dirSize = this.r64(inoOff + INO_SIZE); + const needed = align4(DIRENT_HEADER_SIZE + name.length); + + // No space found — append a new entry at the end. + // Directory entries must not cross block boundaries (like ext2). + let appendPos = dirSize; + let fileBlock = Math.floor(appendPos / BLOCK_SIZE); + let blockOff = appendPos % BLOCK_SIZE; + let reservedPhys = 0; + + if (blockOff !== 0 && blockOff + needed > BLOCK_SIZE) { + // Entry doesn't fit in remaining space — skip to next block. + const gap = BLOCK_SIZE - blockOff; + let padPhys = 0; + if (gap >= DIRENT_HEADER_SIZE) { + padPhys = this.inodeBlockMap(dirIno, fileBlock, false); + if (padPhys <= 0) return EIO; + } else { + if (lastEntAbs < 0) { + lastEntAbs = this.findLastDirEntryInBlock( + dirIno, + fileBlock, + blockOff, + ); + } + if (lastEntAbs < 0) return EIO; + } + + // Reserve the destination block before changing the old tail. If the + // allocation fails, the directory remains byte-for-byte unchanged. + reservedPhys = this.inodeBlockMap(dirIno, fileBlock + 1, true); + if (reservedPhys < 0) return reservedPhys; + if (gap >= DIRENT_HEADER_SIZE) { + // Write a padding entry (ino=0) to fill the gap + const padAbs = padPhys * BLOCK_SIZE + blockOff; + this.w32(padAbs, 0); + this.view.setUint16(padAbs + 4, gap, true); + this.view.setUint16(padAbs + 6, 0, true); + } else { + // Gap too small for a padding entry — extend last entry's recLen + const oldRecLen = this.view.getUint16(lastEntAbs + 4, true); + const newRecLen = oldRecLen + gap; + this.view.setUint16(lastEntAbs + 4, newRecLen, true); + this.updateDirIndexRecLen(dirIno, lastEntAbs, newRecLen); + } + appendPos = (fileBlock + 1) * BLOCK_SIZE; + fileBlock++; + blockOff = 0; + } + + // Need a new block? + let phys: number; + if (blockOff === 0) { + phys = reservedPhys || this.inodeBlockMap(dirIno, fileBlock, true); + if (phys < 0) return phys; + } else { + phys = this.inodeBlockMap(dirIno, fileBlock, false); if (phys <= 0) return EIO; + } - const blockBase = phys * BLOCK_SIZE; - let remain = dirSize - pos; - if (remain > BLOCK_SIZE - blockOff) remain = BLOCK_SIZE - blockOff; - - let off = blockOff; - while (off < blockOff + remain) { - const abs = blockBase + off; - const entIno = this.r32(abs); - const recLen = this.view.getUint16(abs + 4, true); - const entNameLen = this.view.getUint16(abs + 6, true); - - if (recLen === 0) return EIO; + const abs = phys * BLOCK_SIZE + blockOff; + this.w32(abs, childIno); + this.view.setUint16(abs + 4, needed, true); + this.view.setUint16(abs + 6, name.length, true); + this.u8.set(name, abs + DIRENT_HEADER_SIZE); - if (entIno !== 0 && entNameLen === name.length) { - let match = true; - for (let i = 0; i < name.length; i++) { - if (this.u8[abs + DIRENT_HEADER_SIZE + i] !== name[i]) { - match = false; - break; - } - } - if (match) return entIno; - } - off += recLen; - } - pos += remain; - } - return ENOENT; + this.w64(inoOff + INO_SIZE, appendPos + needed); + this.touchDirectoryMutation(dirIno); + this.updateDirIndexAdd(dirIno, name, childIno, abs, needed); + return 0; } private dirAddEntry( @@ -849,6 +1719,16 @@ export class SharedFS { name: Uint8Array, childIno: number, ): number { + const index = this.getDirIndex(dirIno); + if (typeof index === "number") return index; + if (index) { + if (this.useDirIndexFreeSlot(index, dirIno, name, childIno)) return 0; + + // When no indexed deleted slot is available, append instead of scanning + // every existing record again solely to discover internal slack. + return this.dirAppendEntry(dirIno, name, childIno); + } + const inoOff = this.inodeOffset(dirIno); const dirSize = this.r64(inoOff + INO_SIZE); const needed = align4(DIRENT_HEADER_SIZE + name.length); @@ -875,13 +1755,21 @@ export class SharedFS { const recLen = this.view.getUint16(abs + 4, true); const entNameLen = this.view.getUint16(abs + 6, true); - if (recLen === 0) return EIO; + if ( + recLen < DIRENT_HEADER_SIZE || + recLen % 4 !== 0 || + off + recLen > blockOff + remain || + entNameLen > recLen - DIRENT_HEADER_SIZE + ) + return EIO; if (entIno === 0 && recLen >= needed) { // Reuse deleted entry this.w32(abs, childIno); this.view.setUint16(abs + 6, name.length, true); this.u8.set(name, abs + DIRENT_HEADER_SIZE); + this.touchDirectoryMutation(dirIno); + this.updateDirIndexAdd(dirIno, name, childIno, abs, recLen); return 0; } @@ -896,6 +1784,8 @@ export class SharedFS { this.view.setUint16(newAbs + 4, slack, true); this.view.setUint16(newAbs + 6, name.length, true); this.u8.set(name, newAbs + DIRENT_HEADER_SIZE); + this.touchDirectoryMutation(dirIno); + this.updateDirIndexAdd(dirIno, name, childIno, newAbs, slack); return 0; } @@ -905,55 +1795,108 @@ export class SharedFS { pos += remain; } - // No space found — append a new entry at the end. - // Directory entries must not cross block boundaries (like ext2). - let appendPos = dirSize; - let fileBlock = Math.floor(appendPos / BLOCK_SIZE); - let blockOff = appendPos % BLOCK_SIZE; + return this.dirAppendEntry(dirIno, name, childIno, lastEntAbs); + } - if (blockOff !== 0 && blockOff + needed > BLOCK_SIZE) { - // Entry doesn't fit in remaining space — skip to next block. - const gap = BLOCK_SIZE - blockOff; - if (gap >= DIRENT_HEADER_SIZE) { - // Write a padding entry (ino=0) to fill the gap - const padPhys = this.inodeBlockMap(dirIno, fileBlock, false); - if (padPhys > 0) { - const padAbs = padPhys * BLOCK_SIZE + blockOff; - this.w32(padAbs, 0); - this.view.setUint16(padAbs + 4, gap, true); - this.view.setUint16(padAbs + 6, 0, true); - } - } else if (lastEntAbs >= 0) { - // Gap too small for a padding entry — extend last entry's recLen - const oldRecLen = this.view.getUint16(lastEntAbs + 4, true); - this.view.setUint16(lastEntAbs + 4, oldRecLen + gap, true); + private dirRemoveEntry(dirIno: number, name: Uint8Array): number { + const index = this.getDirIndex(dirIno); + if (typeof index === "number") return index; + if (index) { + const key = this.dirNameKey(name); + const entry = index.entries.get(key); + if (!entry) return ENOENT; + + if ( + this.r32(entry.abs) === entry.ino && + this.view.getUint16(entry.abs + 4, true) === entry.recLen && + this.view.getUint16(entry.abs + 6, true) === entry.nameLen && + this.dirEntryNameMatches(entry.abs, name) + ) { + this.w32(entry.abs, 0); // mark as deleted + index.entries.delete(key); + index.free.push({ abs: entry.abs, recLen: entry.recLen }); + this.touchDirectoryMutation(dirIno); + return 0; } - appendPos = (fileBlock + 1) * BLOCK_SIZE; - fileBlock++; - blockOff = 0; + + index.entries.delete(key); + // Fall through to the linear scan below if the cached slot was stale. } - // Need a new block? - let phys: number; - if (blockOff === 0) { - phys = this.inodeBlockMap(dirIno, fileBlock, true); - if (phys < 0) return phys; - } else { - phys = this.inodeBlockMap(dirIno, fileBlock, false); + const inoOff = this.inodeOffset(dirIno); + const dirSize = this.r64(inoOff + INO_SIZE); + let pos = 0; + + while (pos < dirSize) { + const fileBlock = Math.floor(pos / BLOCK_SIZE); + const blockOff = pos % BLOCK_SIZE; + const phys = this.inodeBlockMap(dirIno, fileBlock, false); if (phys <= 0) return EIO; - } - const abs = phys * BLOCK_SIZE + blockOff; - this.w32(abs, childIno); - this.view.setUint16(abs + 4, needed, true); - this.view.setUint16(abs + 6, name.length, true); - this.u8.set(name, abs + DIRENT_HEADER_SIZE); + const blockBase = phys * BLOCK_SIZE; + let remain = dirSize - pos; + if (remain > BLOCK_SIZE - blockOff) remain = BLOCK_SIZE - blockOff; - this.w64(inoOff + INO_SIZE, appendPos + needed); - return 0; + let off = blockOff; + while (off < blockOff + remain) { + const abs = blockBase + off; + const entIno = this.r32(abs); + const recLen = this.view.getUint16(abs + 4, true); + const entNameLen = this.view.getUint16(abs + 6, true); + + if (!this.isValidDirEntry(off, blockOff + remain, recLen, entNameLen)) + return EIO; + + if (entIno !== 0 && entNameLen === name.length) { + let match = true; + for (let i = 0; i < name.length; i++) { + if (this.u8[abs + DIRENT_HEADER_SIZE + i] !== name[i]) { + match = false; + break; + } + } + if (match) { + this.w32(abs, 0); // mark as deleted + this.touchDirectoryMutation(dirIno); + this.updateDirIndexRemove(dirIno, name); + return 0; + } + } + off += recLen; + } + pos += remain; + } + return ENOENT; } - private dirRemoveEntry(dirIno: number, name: Uint8Array): number { + private dirReplaceEntryIno( + dirIno: number, + name: Uint8Array, + childIno: number, + ): number { + const index = this.getDirIndex(dirIno); + if (typeof index === "number") return index; + if (index) { + const key = this.dirNameKey(name); + const entry = index.entries.get(key); + + if ( + entry && + this.r32(entry.abs) === entry.ino && + this.view.getUint16(entry.abs + 4, true) === entry.recLen && + this.view.getUint16(entry.abs + 6, true) === entry.nameLen && + this.dirEntryNameMatches(entry.abs, name) + ) { + this.w32(entry.abs, childIno); + entry.ino = childIno; + this.touchDirectoryMutation(dirIno); + return 0; + } + + if (entry) index.entries.delete(key); + // Fall through to the linear scan below if the cached slot was stale. + } + const inoOff = this.inodeOffset(dirIno); const dirSize = this.r64(inoOff + INO_SIZE); let pos = 0; @@ -975,7 +1918,8 @@ export class SharedFS { const recLen = this.view.getUint16(abs + 4, true); const entNameLen = this.view.getUint16(abs + 6, true); - if (recLen === 0) return EIO; + if (!this.isValidDirEntry(off, blockOff + remain, recLen, entNameLen)) + return EIO; if (entIno !== 0 && entNameLen === name.length) { let match = true; @@ -986,7 +1930,9 @@ export class SharedFS { } } if (match) { - this.w32(abs, 0); // mark as deleted + this.w32(abs, childIno); + this.touchDirectoryMutation(dirIno); + this.updateDirIndexAdd(dirIno, name, childIno, abs, recLen); return 0; } } @@ -1006,7 +1952,7 @@ export class SharedFS { const fileBlock = Math.floor(pos / BLOCK_SIZE); const blockOff = pos % BLOCK_SIZE; const phys = this.inodeBlockMap(dirIno, fileBlock, false); - if (phys <= 0) return true; + if (phys <= 0) throw new SFSError(EIO); const blockBase = phys * BLOCK_SIZE; let remain = dirSize - pos; @@ -1019,12 +1965,17 @@ export class SharedFS { const recLen = this.view.getUint16(abs + 4, true); const entNameLen = this.view.getUint16(abs + 6, true); - if (recLen === 0) break; + if ( + recLen < DIRENT_HEADER_SIZE || + recLen % 4 !== 0 || + off + recLen > blockOff + remain || + entNameLen > recLen - DIRENT_HEADER_SIZE + ) + throw new SFSError(EIO); if (entIno !== 0) { // Skip "." and ".." - if (entNameLen === 1 && this.u8[abs + DIRENT_HEADER_SIZE] === 0x2e) - { + if (entNameLen === 1 && this.u8[abs + DIRENT_HEADER_SIZE] === 0x2e) { off += recLen; continue; } @@ -1045,6 +1996,21 @@ export class SharedFS { return true; } + private dirIsAncestor(ancestorIno: number, dirIno: number): boolean { + let cur = dirIno; + + for (let depth = 0; depth < MAX_SYMLINK_HOPS * 1024; depth++) { + if (cur === ancestorIno) return true; + if (cur === ROOT_INO) return false; + + const parent = this.dirLookup(cur, DOTDOT_BYTES); + if (parent < 0 || parent === cur) throw new SFSError(EIO); + cur = parent; + } + + throw new SFSError(EIO); + } + // ── Path resolution ────────────────────────────────────────────── private pathResolve(path: string, followSymlinks: boolean): number { @@ -1059,12 +2025,17 @@ export class SharedFS { const part = parts[pi]; if (part.length > MAX_NAME) return ENAMETOOLONG; - const inoOff = this.inodeOffset(ino); - const mode = this.r32(inoOff + INO_MODE); - if ((mode & S_IFMT) !== S_IFDIR) return ENOTDIR; - const nameBytes = encoder.encode(part); - const childIno = this.dirLookup(ino, nameBytes); + let childIno: number; + this.inodeReadLock(ino); + try { + const inoOff = this.inodeOffset(ino); + const mode = this.r32(inoOff + INO_MODE); + if ((mode & S_IFMT) !== S_IFDIR) return ENOTDIR; + childIno = this.dirLookup(ino, nameBytes); + } finally { + this.inodeReadUnlock(ino); + } if (childIno < 0) return childIno; // Check if child is symlink @@ -1096,18 +2067,14 @@ export class SharedFS { if (target.startsWith("/")) { // Absolute symlink — restart from root ino = ROOT_INO; - const targetParts = target - .split("/") - .filter((p) => p.length > 0); + const targetParts = target.split("/").filter((p) => p.length > 0); const remaining = parts.slice(pi + 1); parts.length = 0; parts.push(...targetParts, ...remaining); pi = -1; // will be incremented to 0 } else { // Relative symlink — splice into remaining path - const targetParts = target - .split("/") - .filter((p) => p.length > 0); + const targetParts = target.split("/").filter((p) => p.length > 0); const remaining = parts.slice(pi + 1); parts.length = pi; parts.push(...targetParts, ...remaining); @@ -1159,15 +2126,17 @@ export class SharedFS { this.w64(base + FD_OFFSET, 0); this.w32(base + FD_FLAGS, flags); this.w32(base + FD_IS_DIR, isDir ? 1 : 0); + if (!this.inodeAddOpenRef(ino)) { + Atomics.store(this.i32, idx, 0); + return ENOENT; + } return i; } } return EMFILE; } - private fdGet( - fd: number, - ): { + private fdGet(fd: number): { base: number; ino: number; offset: number; @@ -1200,6 +2169,8 @@ export class SharedFS { const off = this.inodeOffset(ino); return { ino, + generation: this.r64(off + INO_GENERATION), + dataSequence: this.r32(off + INO_DATA_SEQUENCE), mode: this.r32(off + INO_MODE), linkCount: this.r32(off + INO_LINK_COUNT), size: this.r64(off + INO_SIZE), @@ -1211,11 +2182,130 @@ export class SharedFS { }; } + private namespaceEntryIdentity(ino: number): NamespaceEntryIdentity { + const off = this.inodeOffset(ino); + return { + ino, + generation: this.r64(off + INO_GENERATION), + linkCount: this.r32(off + INO_LINK_COUNT), + mode: this.r32(off + INO_MODE), + }; + } + // ── Public API: File operations ────────────────────────────────── open(path: string, flags: number, createMode: number = 0o644): number { + return this.withNamespaceLock(() => + this.openUnlocked(path, flags, createMode), + ); + } + + /** + * Atomically create or truncate an empty lazy-file stub and capture the + * exact data identity produced by that truncation. Existing callers rely on + * lazy registration replacing a path, while the inode lock ensures a peer + * write either precedes the truncation or advances the captured sequence + * after it. + */ + createLazyStub(path: string, mode: number): StatResult { + return this.withNamespaceLock(() => { + const fd = this.openUnlocked(path, O_WRONLY | O_CREAT, mode); + try { + const entry = this.fdGet(fd); + if (!entry) throw new SFSError(EBADF); + this.inodeWriteLock(entry.ino); + try { + this.inodeTruncate(entry.ino, 0, true); + return this.buildStat(entry.ino); + } finally { + this.inodeWriteUnlock(entry.ino); + } + } finally { + this.closeUnlocked(fd); + } + }); + } + + /** + * Atomically replace a lazy stub only if the path still names the exact + * inode content generation observed before an asynchronous fetch. + */ + replaceIfIdentity( + path: string, + expectedIno: number, + expectedGeneration: number, + expectedDataSequence: number, + data: Uint8Array, + ): boolean { + return this.withNamespaceLock(() => { + // Lazy access follows symlinks, so conditionally replace the resolved + // regular target while still validating its exact inode/data identity. + const ino = this.pathResolve(path, true); + if (ino < 0 || ino !== expectedIno) return false; + const off = this.inodeOffset(ino); + if ( + this.r64(off + INO_GENERATION) !== expectedGeneration || + this.r32(off + INO_DATA_SEQUENCE) !== expectedDataSequence + ) + return false; + if ((this.r32(off + INO_MODE) & S_IFMT) !== S_IFREG) return false; + this.validateFileSize(data.byteLength); + + this.inodeWriteLock(ino); + try { + // Descriptor-based writes do not take the namespace lock. Revalidate + // after acquiring the inode lock so a concurrent guest mutation that + // won the race cannot be overwritten by stale fetched bytes. + if ( + this.r64(off + INO_GENERATION) !== expectedGeneration || + this.r32(off + INO_DATA_SEQUENCE) !== expectedDataSequence + ) + return false; + // Lazy backing is attached only to an untouched empty stub. Refuse to + // replace any concrete content even if malformed metadata happens to + // carry its current sequence. + if (this.r64(off + INO_SIZE) !== 0) return false; + + const originalMtime = this.r64(off + INO_MTIME); + const originalCtime = this.r64(off + INO_CTIME); + + this.inodeTruncate(ino, 0, true); + const written = + data.byteLength > 0 + ? this.inodeWriteData(ino, 0, data, data.byteLength) + : 0; + if (written !== data.byteLength) { + this.inodeTruncate(ino, 0, true); + Atomics.store( + this.i32, + (off + INO_DATA_SEQUENCE) >> 2, + expectedDataSequence, + ); + this.w64(off + INO_MTIME, originalMtime); + this.w64(off + INO_CTIME, originalCtime); + throw new SFSError(written < 0 ? written : ENOSPC); + } + return true; + } finally { + this.inodeWriteUnlock(ino); + } + }); + } + + private openUnlocked( + path: string, + flags: number, + createMode: number = 0o644, + ): number { const accMode = flags & O_ACCMODE; const creating = (flags & O_CREAT) !== 0; + const exclusive = (flags & O_EXCL) !== 0; + + if (creating && exclusive) { + const existing = this.pathResolve(path, false); + if (existing >= 0) throw new SFSError(EEXIST); + if (existing !== ENOENT) throw new SFSError(existing); + } let ino = this.pathResolve(path, true); @@ -1228,6 +2318,7 @@ export class SharedFS { const nameBytes = encoder.encode(name); const existing = this.dirLookup(parentIno, nameBytes); if (existing >= 0) { + if (exclusive) throw new SFSError(EEXIST); ino = existing; } else { const newIno = this.inodeAlloc(); @@ -1264,7 +2355,7 @@ export class SharedFS { } // O_DIRECTORY: reject non-directories - if ((flags & O_DIRECTORY) && (mode & S_IFMT) !== S_IFDIR) { + if (flags & O_DIRECTORY && (mode & S_IFMT) !== S_IFDIR) { throw new SFSError(ENOTDIR); } @@ -1272,31 +2363,33 @@ export class SharedFS { if (flags & O_TRUNC) { if ((mode & S_IFMT) === S_IFDIR) throw new SFSError(EISDIR); this.inodeWriteLock(ino); - this.inodeTruncate(ino, 0); + this.inodeTruncate(ino, 0, true); this.inodeWriteUnlock(ino); } const fd = this.fdAlloc(ino, flags, false); if (fd < 0) throw new SFSError(fd); - // If append, set offset to end - if (flags & O_APPEND) { - const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; - this.w64(base + FD_OFFSET, this.r64(inoOff + INO_SIZE)); - } - return fd; } close(fd: number): void { + this.withNamespaceLock(() => this.closeUnlocked(fd)); + } + + private closeUnlocked(fd: number): void { const entry = this.fdGet(fd); if (!entry) throw new SFSError(EBADF); this.fdFree(fd); + this.inodeDropOpenRef(entry.ino); } read(fd: number, buffer: Uint8Array): number { const entry = this.fdGet(fd); if (!entry) throw new SFSError(EBADF); + const inoOff = this.inodeOffset(entry.ino); + const mode = this.r32(inoOff + INO_MODE); + if ((mode & S_IFMT) === S_IFDIR) throw new SFSError(EISDIR); this.inodeReadLock(entry.ino); try { @@ -1329,6 +2422,12 @@ export class SharedFS { const inoOff = this.inodeOffset(entry.ino); offset = this.r64(inoOff + INO_SIZE); } + if (!Number.isSafeInteger(offset) || offset < 0) { + throw new SFSError(EINVAL); + } + if (offset > MAX_FILE_SIZE || data.length > MAX_FILE_SIZE - offset) { + throw new SFSError(EFBIG); + } const nwritten = this.inodeWriteData( entry.ino, @@ -1336,6 +2435,7 @@ export class SharedFS { data, data.length, ); + if (nwritten < 0) return nwritten; // Update offset const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; this.w64(base + FD_OFFSET, offset + nwritten); @@ -1362,7 +2462,7 @@ export class SharedFS { throw new SFSError(EINVAL); } - if (newOffset < 0) throw new SFSError(EINVAL); + this.validateSeekPosition(newOffset); const base = FD_TABLE_OFFSET + fd * FD_ENTRY_SIZE; this.w64(base + FD_OFFSET, newOffset); @@ -1373,10 +2473,11 @@ export class SharedFS { const entry = this.fdGet(fd); if (!entry) throw new SFSError(EBADF); if ((entry.flags & O_ACCMODE) === O_RDONLY) throw new SFSError(EBADF); + this.validateFileSize(length); this.inodeWriteLock(entry.ino); try { - this.inodeTruncate(entry.ino, length); + this.inodeTruncate(entry.ino, length, true); } finally { this.inodeWriteUnlock(entry.ino); } @@ -1396,6 +2497,10 @@ export class SharedFS { // ── Public API: Path operations ────────────────────────────────── stat(path: string): StatResult { + return this.withNamespaceLock(() => this.statUnlocked(path)); + } + + private statUnlocked(path: string): StatResult { const ino = this.pathResolve(path, true); if (ino < 0) throw new SFSError(ino); this.inodeReadLock(ino); @@ -1407,6 +2512,10 @@ export class SharedFS { } lstat(path: string): StatResult { + return this.withNamespaceLock(() => this.lstatUnlocked(path)); + } + + private lstatUnlocked(path: string): StatResult { const ino = this.pathResolve(path, false); // don't follow symlinks if (ino < 0) throw new SFSError(ino); this.inodeReadLock(ino); @@ -1417,9 +2526,14 @@ export class SharedFS { } } - unlink(path: string): void { + unlink(path: string): NamespaceEntryIdentity { + return this.withNamespaceLock(() => this.unlinkUnlocked(path)); + } + + private unlinkUnlocked(path: string): NamespaceEntryIdentity { const { parentIno, name } = this.pathResolveParent(path); const nameBytes = encoder.encode(name); + const requiresDirectory = path.length > 1 && path.endsWith("/"); this.inodeWriteLock(parentIno); try { @@ -1428,34 +2542,48 @@ export class SharedFS { const childOff = this.inodeOffset(childIno); const mode = this.r32(childOff + INO_MODE); + if (requiresDirectory && (mode & S_IFMT) !== S_IFDIR) { + throw new SFSError(ENOTDIR); + } if ((mode & S_IFMT) === S_IFDIR) throw new SFSError(EISDIR); + const removed = this.namespaceEntryIdentity(childIno); const rc = this.dirRemoveEntry(parentIno, nameBytes); if (rc < 0) throw new SFSError(rc); + let shouldFree = false; this.inodeWriteLock(childIno); - const linkCount = this.r32(childOff + INO_LINK_COUNT); - if (linkCount <= 1) { - this.inodeTruncate(childIno, 0); - this.w32(childOff + INO_LINK_COUNT, 0); - this.inodeWriteUnlock(childIno); - this.inodeFree(childIno); - } else { - this.w32(childOff + INO_LINK_COUNT, linkCount - 1); + try { + shouldFree = this.inodeDropLinkRefLocked(childIno); + } finally { this.inodeWriteUnlock(childIno); } + if (shouldFree) this.inodeFree(childIno); + return removed; } finally { this.inodeWriteUnlock(parentIno); } } - rename(oldPath: string, newPath: string): void { + rename(oldPath: string, newPath: string): RenameIdentityResult { + return this.withNamespaceLock(() => this.renameUnlocked(oldPath, newPath)); + } + + private renameUnlocked( + oldPath: string, + newPath: string, + ): RenameIdentityResult { const { parentIno: oldParent, name: oldName } = this.pathResolveParent(oldPath); const { parentIno: newParent, name: newName } = this.pathResolveParent(newPath); + if (isReservedDirectoryName(oldName) || isReservedDirectoryName(newName)) { + throw new SFSError(EINVAL); + } const oldNameBytes = encoder.encode(oldName); const newNameBytes = encoder.encode(newName); + const oldRequiresDirectory = oldPath.length > 1 && oldPath.endsWith("/"); + const newRequiresDirectory = newPath.length > 1 && newPath.endsWith("/"); // Lock both parents (consistent order to avoid deadlock) const first = Math.min(oldParent, newParent); @@ -1466,46 +2594,119 @@ export class SharedFS { try { const srcIno = this.dirLookup(oldParent, oldNameBytes); if (srcIno < 0) throw new SFSError(srcIno); + const srcOff = this.inodeOffset(srcIno); + const srcMode = this.r32(srcOff + INO_MODE); + const srcType = srcMode & S_IFMT; + const source = this.namespaceEntryIdentity(srcIno); - // Remove any existing entry at destination + if ( + (oldRequiresDirectory || newRequiresDirectory) && + srcType !== S_IFDIR + ) { + throw new SFSError(ENOTDIR); + } + + if (srcType === S_IFDIR && this.dirIsAncestor(srcIno, newParent)) { + throw new SFSError(EINVAL); + } + + // Replace an existing destination entry in place. Removing it before + // allocating/inserting the source can destroy the destination when the + // rename later fails with ENOSPC. const existingIno = this.dirLookup(newParent, newNameBytes); + let removedExistingDirectory = false; + let replaced: NamespaceEntryIdentity | undefined; if (existingIno >= 0) { + if (existingIno === srcIno) { + return { source, replaced: source }; + } + replaced = this.namespaceEntryIdentity(existingIno); const existOff = this.inodeOffset(existingIno); const existMode = this.r32(existOff + INO_MODE); - if ((existMode & S_IFMT) === S_IFDIR) throw new SFSError(EISDIR); - this.dirRemoveEntry(newParent, newNameBytes); - this.inodeWriteLock(existingIno); - this.inodeTruncate(existingIno, 0); - this.w32(existOff + INO_LINK_COUNT, 0); - this.inodeWriteUnlock(existingIno); - this.inodeFree(existingIno); - } + const existType = existMode & S_IFMT; - // Add entry in new directory - const rc = this.dirAddEntry(newParent, newNameBytes, srcIno); - if (rc < 0) throw new SFSError(rc); + if (srcType === S_IFDIR && existType !== S_IFDIR) { + throw new SFSError(ENOTDIR); + } + if (srcType !== S_IFDIR && existType === S_IFDIR) { + throw new SFSError(EISDIR); + } + + let shouldFreeExisting = false; + const existingAlreadyLocked = + existingIno === oldParent || existingIno === newParent; + if (!existingAlreadyLocked) this.inodeWriteLock(existingIno); + try { + if (existType === S_IFDIR && !this.dirIsEmpty(existingIno)) { + throw new SFSError(ENOTEMPTY); + } + const replaceRc = this.dirReplaceEntryIno( + newParent, + newNameBytes, + srcIno, + ); + if (replaceRc < 0) throw new SFSError(replaceRc); + shouldFreeExisting = + existType === S_IFDIR + ? this.inodeOrphanLocked(existingIno) + : this.inodeDropLinkRefLocked(existingIno); + } finally { + if (!existingAlreadyLocked) this.inodeWriteUnlock(existingIno); + } + if (shouldFreeExisting) this.inodeFree(existingIno); + removedExistingDirectory = existType === S_IFDIR; + } else { + const addRc = this.dirAddEntry(newParent, newNameBytes, srcIno); + if (addRc < 0) throw new SFSError(addRc); + } // Remove entry from old directory - this.dirRemoveEntry(oldParent, oldNameBytes); + const removeRc = this.dirRemoveEntry(oldParent, oldNameBytes); + if (removeRc < 0) throw new SFSError(removeRc); // Update link counts for directory renames - const srcOff = this.inodeOffset(srcIno); - const srcMode = this.r32(srcOff + INO_MODE); - if ( - (srcMode & S_IFMT) === S_IFDIR && - oldParent !== newParent - ) { - const oldPOff = this.inodeOffset(oldParent); - this.w32( - oldPOff + INO_LINK_COUNT, - this.r32(oldPOff + INO_LINK_COUNT) - 1, - ); + if (srcType === S_IFDIR) { + if (oldParent !== newParent) { + const oldPOff = this.inodeOffset(oldParent); + this.w32( + oldPOff + INO_LINK_COUNT, + this.r32(oldPOff + INO_LINK_COUNT) - 1, + ); + const newPOff = this.inodeOffset(newParent); + this.w32( + newPOff + INO_LINK_COUNT, + this.r32(newPOff + INO_LINK_COUNT) + 1, + ); + + this.inodeWriteLock(srcIno); + try { + const dotdotRc = this.dirReplaceEntryIno( + srcIno, + DOTDOT_BYTES, + newParent, + ); + if (dotdotRc < 0) throw new SFSError(dotdotRc); + this.w64(srcOff + INO_CTIME, Date.now()); + } finally { + this.inodeWriteUnlock(srcIno); + } + } + + if (removedExistingDirectory) { + const newPOff = this.inodeOffset(newParent); + this.w32( + newPOff + INO_LINK_COUNT, + this.r32(newPOff + INO_LINK_COUNT) - 1, + ); + } + } else if (removedExistingDirectory) { const newPOff = this.inodeOffset(newParent); this.w32( newPOff + INO_LINK_COUNT, - this.r32(newPOff + INO_LINK_COUNT) + 1, + this.r32(newPOff + INO_LINK_COUNT) - 1, ); } + return { source, replaced }; } finally { if (first !== second) this.inodeWriteUnlock(second); this.inodeWriteUnlock(first); @@ -1513,6 +2714,10 @@ export class SharedFS { } mkdir(path: string, mode: number = 0o755): void { + this.withNamespaceLock(() => this.mkdirUnlocked(path, mode)); + } + + private mkdirUnlocked(path: string, mode: number = 0o755): void { const { parentIno, name } = this.pathResolveParent(path); const nameBytes = encoder.encode(name); @@ -1578,7 +2783,12 @@ export class SharedFS { } rmdir(path: string): void { + this.withNamespaceLock(() => this.rmdirUnlocked(path)); + } + + private rmdirUnlocked(path: string): void { const { parentIno, name } = this.pathResolveParent(path); + if (isReservedDirectoryName(name)) throw new SFSError(EINVAL); const nameBytes = encoder.encode(name); this.inodeWriteLock(parentIno); @@ -1590,17 +2800,18 @@ export class SharedFS { const mode = this.r32(childOff + INO_MODE); if ((mode & S_IFMT) !== S_IFDIR) throw new SFSError(ENOTDIR); + let shouldFree = false; this.inodeWriteLock(childIno); try { if (!this.dirIsEmpty(childIno)) throw new SFSError(ENOTEMPTY); - this.dirRemoveEntry(parentIno, nameBytes); - this.inodeTruncate(childIno, 0); - this.w32(childOff + INO_LINK_COUNT, 0); + const removeRc = this.dirRemoveEntry(parentIno, nameBytes); + if (removeRc < 0) throw new SFSError(removeRc); + shouldFree = this.inodeOrphanLocked(childIno); } finally { this.inodeWriteUnlock(childIno); } - this.inodeFree(childIno); + if (shouldFree) this.inodeFree(childIno); // Decrement parent link count const pOff = this.inodeOffset(parentIno); @@ -1611,6 +2822,10 @@ export class SharedFS { } symlink(target: string, linkPath: string): void { + this.withNamespaceLock(() => this.symlinkUnlocked(target, linkPath)); + } + + private symlinkUnlocked(target: string, linkPath: string): void { const { parentIno, name } = this.pathResolveParent(linkPath); const nameBytes = encoder.encode(name); const targetBytes = encoder.encode(target); @@ -1639,14 +2854,25 @@ export class SharedFS { targetBytes, targetBytes.length, ); - if (written < 0) { + if (written !== targetBytes.length) { + if (written > 0) this.inodeTruncate(newIno, 0); this.inodeFree(newIno); - throw new SFSError(written); + throw new SFSError(written < 0 ? written : ENOSPC); } } const rc = this.dirAddEntry(parentIno, nameBytes, newIno); if (rc < 0) { + if (targetBytes.length <= INLINE_SYMLINK_SIZE) { + this.u8.fill( + 0, + newOff + INO_DIRECT, + newOff + INO_DIRECT + INLINE_SYMLINK_SIZE, + ); + this.w64(newOff + INO_SIZE, 0); + } else { + this.inodeTruncate(newIno, 0); + } this.inodeFree(newIno); throw new SFSError(rc); } @@ -1656,6 +2882,10 @@ export class SharedFS { } chmod(path: string, mode: number): void { + this.withNamespaceLock(() => this.chmodUnlocked(path, mode)); + } + + private chmodUnlocked(path: string, mode: number): void { const ino = this.pathResolve(path, true); if (ino < 0) throw new SFSError(ino); this.inodeWriteLock(ino); @@ -1684,6 +2914,10 @@ export class SharedFS { } chown(path: string, uid: number, gid: number): void { + this.withNamespaceLock(() => this.chownUnlocked(path, uid, gid)); + } + + private chownUnlocked(path: string, uid: number, gid: number): void { const ino = this.pathResolve(path, true); // POSIX chown follows symlinks if (ino < 0) throw new SFSError(ino); this.inodeWriteLock(ino); @@ -1714,6 +2948,10 @@ export class SharedFS { } lchown(path: string, uid: number, gid: number): void { + this.withNamespaceLock(() => this.lchownUnlocked(path, uid, gid)); + } + + private lchownUnlocked(path: string, uid: number, gid: number): void { const ino = this.pathResolve(path, false); // no-follow: chowns the symlink itself if (ino < 0) throw new SFSError(ino); this.inodeWriteLock(ino); @@ -1727,7 +2965,25 @@ export class SharedFS { } } - utimens(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void { + utimens( + path: string, + atimeSec: number, + atimeNsec: number, + mtimeSec: number, + mtimeNsec: number, + ): void { + this.withNamespaceLock(() => + this.utimensUnlocked(path, atimeSec, atimeNsec, mtimeSec, mtimeNsec), + ); + } + + private utimensUnlocked( + path: string, + atimeSec: number, + atimeNsec: number, + mtimeSec: number, + mtimeNsec: number, + ): void { const ino = this.pathResolve(path, true); if (ino < 0) throw new SFSError(ino); this.inodeWriteLock(ino); @@ -1737,11 +2993,17 @@ export class SharedFS { const UTIME_OMIT = 0x3ffffffe; const now = Date.now(); if (atimeNsec !== UTIME_OMIT) { - const atimeMs = atimeNsec === UTIME_NOW ? now : atimeSec * 1000 + Math.floor(atimeNsec / 1_000_000); + const atimeMs = + atimeNsec === UTIME_NOW + ? now + : atimeSec * 1000 + Math.floor(atimeNsec / 1_000_000); this.w64(off + INO_ATIME, atimeMs); } if (mtimeNsec !== UTIME_OMIT) { - const mtimeMs = mtimeNsec === UTIME_NOW ? now : mtimeSec * 1000 + Math.floor(mtimeNsec / 1_000_000); + const mtimeMs = + mtimeNsec === UTIME_NOW + ? now + : mtimeSec * 1000 + Math.floor(mtimeNsec / 1_000_000); this.w64(off + INO_MTIME, mtimeMs); } this.w64(off + INO_CTIME, now); @@ -1750,8 +3012,19 @@ export class SharedFS { } } - link(existingPath: string, newPath: string): void { - const srcIno = this.pathResolve(existingPath, true); + link(existingPath: string, newPath: string): NamespaceEntryIdentity { + return this.withNamespaceLock(() => + this.linkUnlocked(existingPath, newPath), + ); + } + + private linkUnlocked( + existingPath: string, + newPath: string, + ): NamespaceEntryIdentity { + // Select POSIX link(2)'s permitted no-follow behavior for a final + // symlink, which also matches linkat() when AT_SYMLINK_FOLLOW is clear. + const srcIno = this.pathResolve(existingPath, false); if (srcIno < 0) throw new SFSError(srcIno); const srcOff = this.inodeOffset(srcIno); const srcMode = this.r32(srcOff + INO_MODE); @@ -1774,12 +3047,20 @@ export class SharedFS { } finally { this.inodeWriteUnlock(srcIno); } + return { + ...this.namespaceEntryIdentity(srcIno), + linkCount: this.r32(srcOff + INO_LINK_COUNT), + }; } finally { this.inodeWriteUnlock(parentIno); } } readlink(path: string): string { + return this.withNamespaceLock(() => this.readlinkUnlocked(path)); + } + + private readlinkUnlocked(path: string): string { const ino = this.pathResolve(path, false); if (ino < 0) throw new SFSError(ino); @@ -1807,6 +3088,10 @@ export class SharedFS { // ── Public API: Directory reading ──────────────────────────────── opendir(path: string): number { + return this.withNamespaceLock(() => this.opendirUnlocked(path)); + } + + private opendirUnlocked(path: string): number { const ino = this.pathResolve(path, true); if (ino < 0) throw new SFSError(ino); @@ -1819,7 +3104,11 @@ export class SharedFS { return dd; } - readdirEntry( + readdirEntry(dd: number): { name: string; stat: StatResult } | null { + return this.withNamespaceLock(() => this.readdirEntryUnlocked(dd)); + } + + private readdirEntryUnlocked( dd: number, ): { name: string; stat: StatResult } | null { const entry = this.fdGet(dd); @@ -1833,14 +3122,22 @@ export class SharedFS { const fileBlock = Math.floor(pos / BLOCK_SIZE); const blockOff = pos % BLOCK_SIZE; const phys = this.inodeBlockMap(entry.ino, fileBlock, false); - if (phys <= 0) return null; + if (phys <= 0) throw new SFSError(EIO); const abs = phys * BLOCK_SIZE + blockOff; const entIno = this.r32(abs); const recLen = this.view.getUint16(abs + 4, true); const entNameLen = this.view.getUint16(abs + 6, true); - if (recLen === 0) return null; + if ( + !this.isValidDirEntry( + blockOff, + Math.min(BLOCK_SIZE, blockOff + dirSize - pos), + recLen, + entNameLen, + ) + ) + throw new SFSError(EIO); // Advance offset — update both the SAB (persistent) and the local // snapshot so the while loop progresses past deleted entries (entIno=0). @@ -1849,6 +3146,10 @@ export class SharedFS { this.w64(base + FD_OFFSET, pos + recLen); if (entIno !== 0) { + if (entIno >= this.r32(SB_TOTAL_INODES)) throw new SFSError(EIO); + const ibStart = this.r32(SB_INODE_BITMAP_START) * BLOCK_SIZE; + const word = this.r32(ibStart + (entIno >> 5) * 4); + if ((word & (1 << (entIno & 31))) === 0) throw new SFSError(EIO); const nameStr = safeDecode( this.u8.subarray( abs + DIRENT_HEADER_SIZE, diff --git a/host/src/vfs/time.ts b/host/src/vfs/time.ts index 0136cd676b..4a9f82c6da 100644 --- a/host/src/vfs/time.ts +++ b/host/src/vfs/time.ts @@ -23,8 +23,8 @@ export class NodeTimeProvider implements TimeProvider { const elapsed = ns - this._startNs; return { sec: Number(elapsed / 1000000000n), nsec: Number(elapsed % 1000000000n) }; } - if (clockId === 1) { - // CLOCK_MONOTONIC + if (clockId === 1 || clockId === 7) { + // CLOCK_MONOTONIC / CLOCK_BOOTTIME return { sec: Number(ns / 1000000000n), nsec: Number(ns % 1000000000n) }; } // CLOCK_REALTIME — use hrtime + epoch offset for nanosecond resolution @@ -43,8 +43,8 @@ export class NodeTimeProvider implements TimeProvider { export class BrowserTimeProvider implements TimeProvider { clockGettime(clockId: number): { sec: number; nsec: number } { - if (clockId === 1 || clockId === 2 || clockId === 3) { - // CLOCK_MONOTONIC / CLOCK_PROCESS_CPUTIME_ID / CLOCK_THREAD_CPUTIME_ID + if (clockId === 1 || clockId === 2 || clockId === 3 || clockId === 7) { + // CLOCK_MONOTONIC / CPU-time clocks / CLOCK_BOOTTIME const ms = performance.now(); return { sec: Math.floor(ms / 1000), nsec: Math.floor((ms % 1000) * 1_000_000) }; } diff --git a/host/src/vfs/types.ts b/host/src/vfs/types.ts index de510aa39b..3867f2bb9c 100644 --- a/host/src/vfs/types.ts +++ b/host/src/vfs/types.ts @@ -1,4 +1,4 @@ -import type { StatResult, StatfsResult } from "../types"; +import type { PathconfValue, StatResult, StatfsResult } from "../types"; export interface DirEntry { name: string; @@ -14,6 +14,7 @@ export interface FileSystemBackend { write(handle: number, buffer: Uint8Array, offset: number | null, length: number): number; seek(handle: number, offset: number, whence: number): number; fstat(handle: number): StatResult; + fpathconf(handle: number, name: number): PathconfValue; ftruncate(handle: number, length: number): void; fsync(handle: number): void; fchmod(handle: number, mode: number): void; @@ -23,6 +24,7 @@ export interface FileSystemBackend { stat(path: string): StatResult; lstat(path: string): StatResult; statfs(path: string): StatfsResult; + pathconf(path: string, name: number): PathconfValue; mkdir(path: string, mode: number): void; rmdir(path: string): void; unlink(path: string): void; @@ -32,6 +34,7 @@ export interface FileSystemBackend { readlink(path: string): string; chmod(path: string, mode: number): void; chown(path: string, uid: number, gid: number): void; + lchown(path: string, uid: number, gid: number): void; access(path: string, mode: number): void; utimensat(path: string, atimeSec: number, atimeNsec: number, mtimeSec: number, mtimeNsec: number): void; diff --git a/host/src/vfs/vfs.ts b/host/src/vfs/vfs.ts index 708f8e7b41..53c8a04926 100644 --- a/host/src/vfs/vfs.ts +++ b/host/src/vfs/vfs.ts @@ -1,13 +1,21 @@ -import type { NetworkIO, PlatformIO, StatResult, StatfsResult } from "../types"; +import type { + NetworkIO, + PathconfValue, + PlatformIO, + StatResult, + StatfsResult, +} from "../types"; import type { FileSystemBackend, MountConfig, TimeProvider } from "./types"; interface MountEntry { prefix: string; backend: FileSystemBackend; + backendId: number; } interface HandleInfo { backend: FileSystemBackend; + backendId: number; localHandle: number; } @@ -29,11 +37,24 @@ export class VirtualPlatformIO implements PlatformIO { network?: NetworkIO; constructor(mounts: MountConfig[], time: TimeProvider) { + // Scope inode numbers to the backend object that owns them. Assigning the + // id per backend (rather than per mount point) keeps aliases intact when + // one backend is deliberately exposed at more than one mount point. + const backendIds = new Map(); + let nextBackendId = 1; this.mounts = mounts - .map((m) => ({ - prefix: normalizeMountPoint(m.mountPoint), - backend: m.backend, - })) + .map((m) => { + let backendId = backendIds.get(m.backend); + if (backendId === undefined) { + backendId = nextBackendId++; + backendIds.set(m.backend, backendId); + } + return { + prefix: normalizeMountPoint(m.mountPoint), + backend: m.backend, + backendId, + }; + }) .sort((a, b) => b.prefix.length - a.prefix.length); this.time = time; if (this.mounts.length === 0) { @@ -43,16 +64,25 @@ export class VirtualPlatformIO implements PlatformIO { private resolve(path: string): { backend: FileSystemBackend; + backendId: number; relativePath: string; } { for (const m of this.mounts) { if (m.prefix === "/") { - return { backend: m.backend, relativePath: path }; + return { + backend: m.backend, + backendId: m.backendId, + relativePath: path, + }; } if (path === m.prefix || path.startsWith(m.prefix + "/")) { let rel = path.slice(m.prefix.length); if (!rel.startsWith("/")) rel = "/" + rel; - return { backend: m.backend, relativePath: rel }; + return { + backend: m.backend, + backendId: m.backendId, + relativePath: rel, + }; } } throw new Error(`ENOENT: no mount for path: ${path}`); @@ -82,13 +112,25 @@ export class VirtualPlatformIO implements PlatformIO { return info; } + fileIdentity(path: string, dev: bigint, ino: bigint): string | null { + if (ino <= 0n || dev < 0n) return null; + const { backendId } = this.resolve(path); + return `vfs:${backendId}:${dev}:${ino}`; + } + + fileHandleIdentity(handle: number, dev: bigint, ino: bigint): string | null { + if (ino <= 0n || dev < 0n) return null; + const { backendId } = this.getFileHandle(handle); + return `vfs:${backendId}:${dev}:${ino}`; + } + // --- File handle operations --- open(path: string, flags: number, mode: number): number { - const { backend, relativePath } = this.resolve(path); + const { backend, backendId, relativePath } = this.resolve(path); const localHandle = backend.open(relativePath, flags, mode); const globalHandle = this.nextFileHandle++; - this.fileHandles.set(globalHandle, { backend, localHandle }); + this.fileHandles.set(globalHandle, { backend, backendId, localHandle }); return globalHandle; } @@ -129,6 +171,11 @@ export class VirtualPlatformIO implements PlatformIO { return info.backend.fstat(info.localHandle); } + fpathconf(handle: number, name: number): PathconfValue { + const info = this.getFileHandle(handle); + return info.backend.fpathconf(info.localHandle, name); + } + ftruncate(handle: number, length: number): void { const info = this.getFileHandle(handle); info.backend.ftruncate(info.localHandle, length); @@ -166,6 +213,11 @@ export class VirtualPlatformIO implements PlatformIO { return backend.statfs(relativePath); } + pathconf(path: string, name: number): PathconfValue { + const { backend, relativePath } = this.resolve(path); + return backend.pathconf(relativePath, name); + } + mkdir(path: string, mode: number): void { const { backend, relativePath } = this.resolve(path); backend.mkdir(relativePath, mode); @@ -211,6 +263,11 @@ export class VirtualPlatformIO implements PlatformIO { backend.chown(relativePath, uid, gid); } + lchown(path: string, uid: number, gid: number): void { + const { backend, relativePath } = this.resolve(path); + backend.lchown(relativePath, uid, gid); + } + access(path: string, mode: number): void { const { backend, relativePath } = this.resolve(path); backend.access(relativePath, mode); @@ -224,10 +281,10 @@ export class VirtualPlatformIO implements PlatformIO { // --- Directory operations --- opendir(path: string): number { - const { backend, relativePath } = this.resolve(path); + const { backend, backendId, relativePath } = this.resolve(path); const localHandle = backend.opendir(relativePath); const globalHandle = this.nextDirHandle++; - this.dirHandles.set(globalHandle, { backend, localHandle }); + this.dirHandles.set(globalHandle, { backend, backendId, localHandle }); return globalHandle; } diff --git a/host/src/vm-interrupt-timer.ts b/host/src/vm-interrupt-timer.ts new file mode 100644 index 0000000000..9886097156 --- /dev/null +++ b/host/src/vm-interrupt-timer.ts @@ -0,0 +1,230 @@ +/** + * A process-scoped cooperative VM-interrupt timer. + * + * Process workers cannot reliably run their own timer while executing a + * CPU-bound Wasm loop. The kernel worker owns this timer instead and writes + * the runtime's interrupt flags through the process's shared memory. + * + * The generation object is deliberately part of every entry. A numeric PID + * can be reused by exec or a later process, so neither a queued timer callback + * nor a stale worker message may act on whatever generation happens to own the + * PID later. + */ + +export const MAX_VM_INTERRUPT_TIMER_DELAY_MS = 0x7fffffff; + +export interface VmInterruptProcessGeneration { + readonly memory: WebAssembly.Memory; +} + +export interface VmInterruptTimerRequest { + timedOutPtr: number; + vmInterruptPtr: number; + seconds: number; +} + +export interface VmInterruptTimerScheduler> { + /** Monotonic milliseconds. */ + now(): number; + set(callback: () => void, delayMs: number): Handle; + clear(handle: Handle): void; +} + +interface TimerEntry { + generation: Generation; + deadlineMs: number; + timedOutPtr: number; + vmInterruptPtr: number; + handle?: Handle; +} + +function defaultScheduler(): VmInterruptTimerScheduler { + return { + now: () => performance.now(), + set: (callback, delayMs) => setTimeout(callback, delayMs), + clear: (handle) => clearTimeout(handle), + }; +} + +function sharedFlags( + generation: VmInterruptProcessGeneration, + timedOutPtr: number, + vmInterruptPtr: number, +): Uint8Array | null { + // TypeScript's WebAssembly.Memory.buffer declaration is ArrayBuffer even + // when the memory descriptor used `shared: true`; narrow from unknown so a + // real SharedArrayBuffer remains representable without an impossible + // ArrayBuffer & SharedArrayBuffer intersection. + const buffer: unknown = generation.memory.buffer; + if ( + typeof SharedArrayBuffer === "undefined" || + !(buffer instanceof SharedArrayBuffer) + ) { + return null; + } + if ( + !Number.isSafeInteger(timedOutPtr) || + timedOutPtr < 0 || + timedOutPtr >= buffer.byteLength || + !Number.isSafeInteger(vmInterruptPtr) || + vmInterruptPtr < 0 || + vmInterruptPtr >= buffer.byteLength + ) { + return null; + } + return new Uint8Array(buffer); +} + +export class VmInterruptTimerManager< + Generation extends VmInterruptProcessGeneration, + Handle = ReturnType, +> { + private readonly entries = new Map>(); + + constructor( + private readonly currentGeneration: (pid: number) => Generation | undefined, + private readonly scheduler: VmInterruptTimerScheduler = + defaultScheduler() as VmInterruptTimerScheduler, + ) {} + + /** + * Apply the runtime hook's arm/cancel request for the listener-owned PID. + * Non-positive durations cancel the current generation's timer. + */ + handleRequest( + pid: number, + generation: Generation, + request: VmInterruptTimerRequest, + ): boolean { + if (request.seconds > 0) { + return this.arm(pid, generation, request); + } + return this.cancel(pid, generation); + } + + /** Replace any existing timer for the same current process generation. */ + arm( + pid: number, + generation: Generation, + request: VmInterruptTimerRequest, + ): boolean { + if (this.currentGeneration(pid) !== generation) return false; + + // A new request replaces the previous timer even when the new request is + // malformed. This matches timer-set semantics and prevents an old deadline + // from surviving a rejected re-arm. + this.clear(pid); + + if (!Number.isFinite(request.seconds) || !(request.seconds > 0)) return false; + if (!sharedFlags(generation, request.timedOutPtr, request.vmInterruptPtr)) { + return false; + } + + const now = this.scheduler.now(); + const delayMs = request.seconds * 1000; + const deadlineMs = now + delayMs; + if (!Number.isFinite(now) || !Number.isFinite(delayMs) || !Number.isFinite(deadlineMs)) { + return false; + } + + const entry: TimerEntry = { + generation, + deadlineMs, + timedOutPtr: request.timedOutPtr, + vmInterruptPtr: request.vmInterruptPtr, + }; + this.entries.set(pid, entry); + this.schedule(pid, entry); + return true; + } + + /** Cancel only when the caller still owns the current PID generation. */ + cancel(pid: number, generation: Generation): boolean { + if (this.currentGeneration(pid) !== generation) return false; + this.clear(pid, generation); + return true; + } + + /** Clear a PID timer, optionally restricted to one exact generation. */ + clear(pid: number, generation?: Generation): boolean { + const entry = this.entries.get(pid); + if (!entry || (generation !== undefined && entry.generation !== generation)) { + return false; + } + if (entry.handle !== undefined) { + this.scheduler.clear(entry.handle); + entry.handle = undefined; + } + this.entries.delete(pid); + return true; + } + + clearAll(): void { + for (const [pid] of this.entries) this.clear(pid); + } + + get activeCount(): number { + return this.entries.size; + } + + private schedule(pid: number, entry: TimerEntry): void { + if ( + this.entries.get(pid) !== entry || + this.currentGeneration(pid) !== entry.generation + ) { + this.discardIfCurrent(pid, entry); + return; + } + + const remainingMs = entry.deadlineMs - this.scheduler.now(); + if (remainingMs <= 0) { + this.fire(pid, entry); + return; + } + + // Browser and Node timers clamp/overflow beyond a signed 32-bit delay. + // Schedule in chunks and recompute against the monotonic deadline after + // every wake. Ceil prevents a fractional delay from firing early. + const delayMs = Math.min( + MAX_VM_INTERRUPT_TIMER_DELAY_MS, + Math.max(1, Math.ceil(remainingMs)), + ); + const handle = this.scheduler.set(() => { + if (this.entries.get(pid) !== entry || entry.handle !== handle) return; + entry.handle = undefined; + this.schedule(pid, entry); + }, delayMs); + entry.handle = handle; + } + + private fire(pid: number, entry: TimerEntry): void { + if ( + this.entries.get(pid) !== entry || + this.currentGeneration(pid) !== entry.generation + ) { + this.discardIfCurrent(pid, entry); + return; + } + + const flags = sharedFlags( + entry.generation, + entry.timedOutPtr, + entry.vmInterruptPtr, + ); + this.entries.delete(pid); + entry.handle = undefined; + if (!flags) return; + + Atomics.store(flags, entry.timedOutPtr, 1); + Atomics.store(flags, entry.vmInterruptPtr, 1); + } + + private discardIfCurrent(pid: number, entry: TimerEntry): void { + if (this.entries.get(pid) !== entry) return; + if (entry.handle !== undefined) { + this.scheduler.clear(entry.handle); + entry.handle = undefined; + } + this.entries.delete(pid); + } +} diff --git a/host/src/worker-adapter.ts b/host/src/worker-adapter.ts index 19c919f482..0f9045a0ef 100644 --- a/host/src/worker-adapter.ts +++ b/host/src/worker-adapter.ts @@ -97,16 +97,49 @@ export class MockWorkerAdapter implements WorkerAdapter { // --- Node.js implementation --- -import { Worker } from "node:worker_threads"; +import { Worker, type WorkerOptions } from "node:worker_threads"; import { pathToFileURL } from "node:url"; import { createRequire } from "node:module"; import { existsSync } from "node:fs"; +// Wasm guest stacks consume the embedding worker's native stack when engines +// recurse through Wasm frames. Keep the default high enough for stack-heavy +// POSIX workloads while retaining an environment override for constrained +// embedders. +const DEFAULT_NODE_WORKER_STACK_SIZE_MB = 32; + function currentModuleUrl(): string { if (typeof __filename !== "undefined") return pathToFileURL(__filename).href; return import.meta.url; } +/** @internal Exported so the host policy can be validated without spawning a worker. */ +export function nodeWorkerStackSizeMb( + raw = process.env.KANDELO_NODE_WORKER_STACK_SIZE_MB, +): number { + if (raw === undefined || raw === "") return DEFAULT_NODE_WORKER_STACK_SIZE_MB; + const parsed = Number(raw); + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`invalid KANDELO_NODE_WORKER_STACK_SIZE_MB: ${raw}`); + } + return parsed; +} + +/** @internal Exported so tests can verify resource-limit composition. */ +export function nodeWorkerOptions( + workerData: unknown, + options: WorkerOptions = {}, +): WorkerOptions { + return { + ...options, + workerData, + resourceLimits: { + ...options.resourceLimits, + stackSizeMb: nodeWorkerStackSizeMb(), + }, + }; +} + export class NodeWorkerAdapter implements WorkerAdapter { private entryUrl: URL; private _compiledEntry: URL | false | undefined; @@ -153,7 +186,7 @@ export class NodeWorkerAdapter implements WorkerAdapter { // bootstrap which takes >500ms with 10+ concurrent workers). const compiledEntry = this.resolveCompiledEntry(); if (compiledEntry) { - const worker = new Worker(compiledEntry, { workerData }); + const worker = new Worker(compiledEntry, nodeWorkerOptions(workerData)); return new NodeWorkerHandle(worker); } @@ -169,10 +202,9 @@ export class NodeWorkerAdapter implements WorkerAdapter { `await import('${entryUrl}');`, ].join("\n"); - const worker = new Worker(bootstrap, { + const worker = new Worker(bootstrap, nodeWorkerOptions(workerData, { eval: true, - workerData, - }); + })); return new NodeWorkerHandle(worker); } } diff --git a/host/src/worker-main.ts b/host/src/worker-main.ts index 9c72ac3c3d..4539ff7301 100644 --- a/host/src/worker-main.ts +++ b/host/src/worker-main.ts @@ -10,7 +10,18 @@ import type { CentralizedThreadInitMessage, WorkerToHostMessage, } from "./worker-protocol"; -import { DynamicLinker, type LoadedSharedLibrary } from "./dylink"; +import { + createCppExceptionTag, + createLongjmpTag, + DynamicLinker, + FORK_CAP_DYLINK_MAIN, + forkInstrumentRoleAvailable, + readForkInstrumentCapabilityClaim, + requireCppExceptionTag, + requireLongjmpTag, + type LoadedSharedLibrary, + type SideModuleForkState, +} from "./dylink"; import { extractAbiVersion } from "./constants"; import { ABI_SYSCALLS, @@ -117,8 +128,11 @@ function buildKernelImports( Atomics.notify(i32, (base + CH_STATUS) / 4, 1); // Wait for complete, then trap while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* */ } - Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); onKernelExit?.(status); + // Per-thread exit is non-returning. Throwing here prevents libc's + // mandated SYS_exit retry loop from parking a second time on a channel + // the host has already removed, and lets the outer worker report exit. + throw new WebAssembly.RuntimeError("unreachable"); }, // Clone dispatches through channel (SYS_CLONE) @@ -180,6 +194,46 @@ export interface DlopenSupport { * fork-child path AFTER setupChannelBase and BEFORE the wpk_fork * rewind into _start. */ replayDlopens: () => void; + /** Finish the one active side-module unwind after the main image unwinds. */ + completeSideModuleForkUnwind: () => void; + /** Begin the active side-module rewind after fork-child dlopen replay. */ + beginSideModuleForkRewind: () => void; + /** Reject a leaked active side-module identity on a normal main return. */ + assertNoActiveSideModuleFork: () => void; + /** Clear a fork parent's copied archive lock in the child's private memory. */ + resetForkChildLock: () => void; +} + +/** + * Thread workers instantiate a separate Wasm module/table/tag graph, so they + * cannot safely load or invoke process side modules. Keep dlopen's ordinary C + * failure contract (NULL plus dlerror text) instead of letting a generic + * unresolved-import stub trap the pthread. + */ +function buildUnsupportedThreadDlopenImports( + memory: WebAssembly.Memory, +): Record { + const message = new TextEncoder().encode( + "dlopen is unsupported from pthread workers; load side modules on the process main worker", + ); + const n = (value: number | bigint): number => + typeof value === "bigint" ? Number(value) : value; + return { + __wasm_dlopen: (): number => 0, + __wasm_dlsym: (): number => 0, + __wasm_dlclose: (): number => -1, + __wasm_dlerror: (bufPtr: number | bigint, bufMax: number | bigint): number => { + const ptr = n(bufPtr); + const max = n(bufMax); + if (!Number.isSafeInteger(ptr) || !Number.isSafeInteger(max) || ptr < 0 || max <= 0) { + return 0; + } + const len = Math.min(message.length, max, memory.buffer.byteLength - ptr); + if (len <= 0) return 0; + new Uint8Array(memory.buffer, ptr, len).set(message.subarray(0, len)); + return len; + }, + }; } /** @@ -190,26 +244,41 @@ export interface DlopenSupport { * don't use dlopen. * * Each successful dlopen is also persisted into a per-process archive - * (linked list in linear memory, head pointer at a fixed slot below - * forkBufAddr) so the fork child can replay them via `replayDlopens`. + * (linked list in linear memory, with control slots below the main process + * channel's fork buffer) so the fork child can replay them via + * `replayDlopens`. The archive anchor is deliberately independent of the + * call-site rewind buffer: a fork issued by a pthread rewinds from that + * thread's buffer but still inherits the one process-wide dlopen archive. */ function buildDlopenImports( memory: WebAssembly.Memory, channelOffset: number, - forkBufAddr: number, + archiveControlAddr: number, getTable: () => WebAssembly.Table | undefined, getStackPointer: () => WebAssembly.Global | undefined, getInstance: () => WebAssembly.Instance | undefined, ptrWidth: 4 | 8, + longjmpTag: WebAssembly.Tag | undefined, + cppExceptionTag: WebAssembly.Tag | undefined, + mainHasDylinkForkRole: boolean, ): DlopenSupport { let linker: DynamicLinker | null = null; const loadedLibraries = new Map(); + let activeSideFork: SideModuleForkState | null = null; const decoder = new TextDecoder(); const encoder = new TextEncoder(); const n = (v: number | bigint): number => typeof v === "bigint" ? Number(v) : v; const headOffset = ptrWidth === 8 ? DLOPEN_HEAD_OFFSET_WASM64 : DLOPEN_HEAD_OFFSET_WASM32; - const headSlot = forkBufAddr - headOffset; + const sideForkOffset = ptrWidth === 8 + ? DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 + : DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32; + const lockOffset = ptrWidth === 8 + ? DLOPEN_LOCK_OFFSET_WASM64 + : DLOPEN_LOCK_OFFSET_WASM32; + const headSlot = archiveControlAddr - headOffset; + const activeSideForkSlot = archiveControlAddr - sideForkOffset; + const archiveLock = new Int32Array(memory.buffer, archiveControlAddr - lockOffset, 1); const entrySize = ptrWidth === 8 ? DLOPEN_ENTRY_SIZE_WASM64 : DLOPEN_ENTRY_SIZE_WASM32; const readPtr = (view: DataView, addr: number): number => @@ -218,6 +287,59 @@ function buildDlopenImports( if (ptrWidth === 8) view.setBigUint64(addr, BigInt(value), true); else view.setUint32(addr, value, true); }; + const readArchiveHead = (): number => ptrWidth === 8 + ? Number(Atomics.load(new BigUint64Array(memory.buffer, headSlot, 1), 0)) + : Atomics.load(new Uint32Array(memory.buffer, headSlot, 1), 0); + const writeArchiveHead = (value: number): void => { + if (ptrWidth === 8) { + Atomics.store(new BigUint64Array(memory.buffer, headSlot, 1), 0, BigInt(value)); + } else { + Atomics.store(new Uint32Array(memory.buffer, headSlot, 1), 0, value); + } + }; + const linkerAllocations = new Map(); + let hostDlopenError: string | null = null; + let mainDlopenDepth = 0; + const acquireMainDlopenLock = (): boolean => { + if (mainDlopenDepth > 0) { + mainDlopenDepth++; + return true; + } + const owner = Atomics.compareExchange( + archiveLock, + 0, + DLOPEN_LOCK_IDLE, + DLOPEN_LOCK_WRITER, + ); + if (owner !== 0) { + hostDlopenError = owner > 0 + ? "dlopen is temporarily unavailable while pthreads are forking" + : "dlopen is temporarily unavailable while another dlopen operation owns the process lock"; + return false; + } + mainDlopenDepth = 1; + return true; + }; + const releaseMainDlopenLock = (): void => { + if (mainDlopenDepth <= 0) { + throw new Error("dlopen process lock released without ownership"); + } + mainDlopenDepth--; + if (mainDlopenDepth === 0) { + const owner = Atomics.compareExchange( + archiveLock, + 0, + DLOPEN_LOCK_WRITER, + DLOPEN_LOCK_IDLE, + ); + if (owner !== DLOPEN_LOCK_WRITER) { + throw new Error( + `dlopen process lock lost writer ownership (state=${owner})`, + ); + } + Atomics.notify(archiveLock, 0); + } + }; // The kernel mmap allocator. Shared with the linker, but also used // directly by persistArchiveEntry to obtain blocks for the archive. @@ -245,7 +367,37 @@ function buildDlopenImports( if (err || result < 0) { throw new Error(`dlopen: mmap(${requested}) failed errno=${err || -result}`); } - return alignUp(n(result), Math.max(align, 1)); + const aligned = alignUp(n(result), Math.max(align, 1)); + linkerAllocations.set(aligned, { rawAddr: n(result), length: requested }); + return aligned; + }; + + const deallocateMemory = (addr: number, _size: number): void => { + const allocation = linkerAllocations.get(addr); + if (!allocation) { + throw new Error(`dlopen rollback: unknown allocation 0x${addr.toString(16)}`); + } + const view = new DataView(memory.buffer); + const base = channelOffset; + view.setInt32(base + CH_SYSCALL, ABI_SYSCALLS.Munmap, true); + view.setBigInt64(base + CH_ARGS + 0 * CH_ARG_SIZE, BigInt(allocation.rawAddr), true); + view.setBigInt64(base + CH_ARGS + 1 * CH_ARG_SIZE, BigInt(allocation.length), true); + for (let i = 2; i < 6; i++) { + view.setBigInt64(base + CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + + const i32 = new Int32Array(memory.buffer); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING); + Atomics.notify(i32, (base + CH_STATUS) / 4, 1); + while (Atomics.wait(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_PENDING) === "ok") { /* wait */ } + + const result = Number(view.getBigInt64(base + CH_RETURN, true)); + const err = view.getUint32(base + CH_ERRNO, true); + Atomics.store(i32, (base + CH_STATUS) / 4, CHANNEL_STATUS_IDLE); + if (err || result < 0) { + throw new Error(`dlopen rollback: munmap failed errno=${err || -result}`); + } + linkerAllocations.delete(addr); }; const getLinker = (): DynamicLinker => { @@ -262,7 +414,8 @@ function buildDlopenImports( // not be shadowed by main exports. const RESERVED = new Set([ "memory", "__indirect_function_table", - "__memory_base", "__table_base", "__stack_pointer", + "__memory_base", "__table_base", "__stack_pointer", "__c_longjmp", + "__cpp_exception", ]); const globalSymbols = new Map(); const inst = getInstance(); @@ -275,14 +428,85 @@ function buildDlopenImports( } } + const mainModuleSymbols = new Set(globalSymbols.keys()); + // A main-defined/exported tag is the process ABI authority. If the main + // image instead imports and re-exports the host tag, the identity is the + // same; if it has no export, retain the process-owned fallback created + // before main instantiation. Every side module must receive this one + // canonical identity for cross-module exception propagation. + const exportedLongjmpTag = inst?.exports.__c_longjmp; + const canonicalLongjmpTag = exportedLongjmpTag === undefined + ? longjmpTag + : requireLongjmpTag(exportedLongjmpTag, "main module export"); + const exportedCppExceptionTag = inst?.exports.__cpp_exception; + const canonicalCppExceptionTag = exportedCppExceptionTag === undefined + ? cppExceptionTag + : requireCppExceptionTag(exportedCppExceptionTag, "main module export"); + const mainFork = inst?.exports.fork; + const mainForkState = inst?.exports.wpk_fork_state; + const sideModuleFork = mainHasDylinkForkRole + && typeof mainFork === "function" + && typeof mainForkState === "function" + ? { + setActiveFork: (state: SideModuleForkState) => { + const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); + if (activeSideFork || persisted !== 0) { + throw new Error( + `${state.name}: nested or concurrent side-module fork is unsupported`, + ); + } + activeSideFork = state; + writePtr(new DataView(memory.buffer), activeSideForkSlot, state.forkBufAddr); + }, + clearActiveFork: (state: SideModuleForkState) => { + const view = new DataView(memory.buffer); + const persisted = readPtr(view, activeSideForkSlot); + if ( + !activeSideFork + || activeSideFork.name !== state.name + || activeSideFork.instance !== state.instance + || activeSideFork.forkBufAddr !== state.forkBufAddr + || activeSideFork.forkBufSize !== state.forkBufSize + || persisted !== state.forkBufAddr + ) { + throw new Error(`${state.name}: stale side-module fork identity during rewind`); + } + activeSideFork = null; + writePtr(view, activeSideForkSlot, 0); + }, + invokeMainFork: (expectedStateAfter: 0 | 1): number => { + const result = Number((mainFork as () => number)()); + const actualState = Number((mainForkState as () => number)()); + if (actualState !== expectedStateAfter) { + throw new Error( + `main-module fork transition ended in state ${actualState}; ` + + `expected ${expectedStateAfter}`, + ); + } + return result; + }, + } + : undefined; + linker = new DynamicLinker({ memory, table, stackPointer: sp, allocateMemory, + deallocateMemory, globalSymbols, got: new Map(), loadedLibraries, + longjmpTag: canonicalLongjmpTag, + cppExceptionTag: canonicalCppExceptionTag, + ptrWidth, + mainModuleSymbols, + sideModuleFork, + sideModuleForkUnavailableReason: !mainHasDylinkForkRole + ? "main module lacks the versioned dlopen-main fork capability; rebuild it with the current wasm-fork-instrument" + : sideModuleFork + ? undefined + : "main module does not export the fork trampoline and wpk_fork_state required for side-module fork", }); return linker; }; @@ -291,7 +515,14 @@ function buildDlopenImports( // entry is one mmap block: struct, then name UTF-8 (padded to 8-byte // alignment), then the side-module wasm bytes. Pointers are absolute // — fork's memcpy preserves the parent's address space. - const persistArchiveEntry = (name: string, bytes: Uint8Array, memoryBase: number): void => { + const persistArchiveEntry = ( + name: string, + bytes: Uint8Array, + memoryBase: number, + tableBase: number, + sideForkBufAddr: number, + tlsBase: number, + ): void => { const nameBytes = encoder.encode(name); const nameLen = nameBytes.length; const nameAligned = (nameLen + 7) & ~7; @@ -309,6 +540,9 @@ function buildDlopenImports( view.setBigUint64(entry + 24, BigInt(bytesPtr), true); view.setBigUint64(entry + 32, BigInt(bytes.length), true); view.setBigUint64(entry + 40, BigInt(memoryBase), true); + view.setBigUint64(entry + 48, BigInt(tableBase), true); + view.setBigUint64(entry + 56, BigInt(sideForkBufAddr), true); + view.setBigUint64(entry + 64, BigInt(tlsBase), true); } else { view.setUint32(entry + 0, 0, true); view.setUint32(entry + 4, namePtr, true); @@ -316,15 +550,21 @@ function buildDlopenImports( view.setUint32(entry + 12, bytesPtr, true); view.setUint32(entry + 16, bytes.length, true); view.setUint32(entry + 20, memoryBase, true); + view.setUint32(entry + 24, tableBase, true); + view.setUint32(entry + 28, sideForkBufAddr, true); + view.setUint32(entry + 32, tlsBase, true); } new Uint8Array(memory.buffer, namePtr, nameLen).set(nameBytes); new Uint8Array(memory.buffer, bytesPtr, bytes.length).set(bytes); // Append to tail (preserves insertion order). - const head = readPtr(view, headSlot); + const head = readArchiveHead(); if (head === 0) { - writePtr(view, headSlot, entry); + // Publish only after the complete entry and payload are visible. A + // pthread fork acquire-loads this word before deciding whether it can + // safely fork without access to the process side-module graph. + writeArchiveHead(entry); return; } let cursor = head; @@ -340,7 +580,7 @@ function buildDlopenImports( const replayDlopens = (): void => { const view = new DataView(memory.buffer); - let cursor = readPtr(view, headSlot); + let cursor = readArchiveHead(); if (cursor === 0) return; // Force linker creation: it's lazily built on the first C-side @@ -349,7 +589,15 @@ function buildDlopenImports( const lk = getLinker(); while (cursor !== 0) { - let next: number, namePtr: number, nameLen: number, bytesPtr: number, bytesLen: number, memoryBase: number; + let next: number; + let namePtr: number; + let nameLen: number; + let bytesPtr: number; + let bytesLen: number; + let memoryBase: number; + let tableBase: number; + let sideForkBufAddr: number; + let tlsBase: number; if (ptrWidth === 8) { next = Number(view.getBigUint64(cursor + 0, true)); namePtr = Number(view.getBigUint64(cursor + 8, true)); @@ -357,6 +605,9 @@ function buildDlopenImports( bytesPtr = Number(view.getBigUint64(cursor + 24, true)); bytesLen = Number(view.getBigUint64(cursor + 32, true)); memoryBase = Number(view.getBigUint64(cursor + 40, true)); + tableBase = Number(view.getBigUint64(cursor + 48, true)); + sideForkBufAddr = Number(view.getBigUint64(cursor + 56, true)); + tlsBase = Number(view.getBigUint64(cursor + 64, true)); } else { next = view.getUint32(cursor + 0, true); namePtr = view.getUint32(cursor + 4, true); @@ -364,6 +615,9 @@ function buildDlopenImports( bytesPtr = view.getUint32(cursor + 12, true); bytesLen = view.getUint32(cursor + 16, true); memoryBase = view.getUint32(cursor + 20, true); + tableBase = view.getUint32(cursor + 24, true); + sideForkBufAddr = view.getUint32(cursor + 28, true); + tlsBase = view.getUint32(cursor + 32, true); } // Copy name + bytes out of shared memory before passing to @@ -376,18 +630,108 @@ function buildDlopenImports( const bytesCopy = new Uint8Array(new Uint8Array(memory.buffer, bytesPtr, bytesLen)); // DynamicLinker.dlopenSync returns 0 on error, >0 on success. - const handle = lk.dlopenSync(name, bytesCopy, { memoryBase }); + const handle = lk.dlopenSync(name, bytesCopy, { + memoryBase, + tableBase, + forkBufAddr: sideForkBufAddr || undefined, + tlsBase: tlsBase === 0 ? undefined : tlsBase, + }); if (handle === 0) { throw new Error(`dlopen(${name}): ${lk.dlerror() || "unknown"}`); } + if (sideForkBufAddr !== 0) { + const loaded = loadedLibraries.get(name); + if (!loaded || loaded.forkBufAddr !== sideForkBufAddr) { + throw new Error(`${name}: fork replay restored a mismatched save buffer`); + } + } + if (tlsBase !== 0) { + const loaded = loadedLibraries.get(name); + if (!loaded || loaded.tlsBase !== tlsBase) { + throw new Error(`${name}: fork replay restored a mismatched TLS base`); + } + } cursor = next; } }; + const findActiveSideFork = (): SideModuleForkState | null => { + const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); + if (persisted === 0) { + if (activeSideFork) { + throw new Error(`${activeSideFork.name}: active side fork lost its persisted identity`); + } + return null; + } + if (activeSideFork) { + if (activeSideFork.forkBufAddr !== persisted) { + throw new Error(`${activeSideFork.name}: active side fork buffer identity changed`); + } + return activeSideFork; + } + + const matches = Array.from(loadedLibraries.values()).filter( + (loaded) => loaded.forkBufAddr === persisted, + ); + if (matches.length !== 1) { + throw new Error( + `fork replay could not resolve active side-module buffer 0x${persisted.toString(16)}`, + ); + } + const loaded = matches[0]!; + activeSideFork = { + name: loaded.name, + instance: loaded.instance, + forkBufAddr: persisted, + forkBufSize: FORK_BUF_SIZE, + }; + return activeSideFork; + }; + + const sideForkState = (state: SideModuleForkState): number => + Number((state.instance.exports.wpk_fork_state as () => number)()); + + const completeSideModuleForkUnwind = (): void => { + const state = findActiveSideFork(); + if (!state) return; + finalizeSideModuleForkUnwind(memory, state, ptrWidth); + }; + + const beginSideModuleForkRewind = (): void => { + const state = findActiveSideFork(); + if (!state) return; + if (sideForkState(state) !== 0) { + throw new Error(`${state.name}: expected NORMAL before side-module rewind`); + } + (state.instance.exports.wpk_fork_rewind_begin as (addr: number) => void)( + state.forkBufAddr, + ); + if (sideForkState(state) !== 2) { + throw new Error(`${state.name}: side-module rewind did not enter REWINDING`); + } + }; + + const assertNoActiveSideModuleFork = (): void => { + const persisted = readPtr(new DataView(memory.buffer), activeSideForkSlot); + if (activeSideFork || persisted !== 0) { + throw new Error( + `${activeSideFork?.name ?? "unknown side module"}: main image returned with an active side-module fork`, + ); + } + }; + + const resetForkChildLock = (): void => { + Atomics.store(archiveLock, 0, 0); + Atomics.notify(archiveLock, 0); + }; + const imports: Record = { __wasm_dlopen: (bytesPtr: number, bytesLen: number, namePtr: number, nameLen: number): number => { + if (!acquireMainDlopenLock()) return 0; + hostDlopenError = null; + try { const bytes = new Uint8Array(memory.buffer, bytesPtr, bytesLen); // Copy bytes since memory.buffer may detach during Wasm instantiation const bytesCopy = new Uint8Array(bytes); @@ -408,9 +752,19 @@ function buildDlopenImports( if (!loaded) { throw new Error(`__wasm_dlopen(${name}): handle=${handle} but loadedLibraries lookup failed`); } - persistArchiveEntry(name, bytesCopy, loaded.memoryBase); + persistArchiveEntry( + name, + bytesCopy, + loaded.memoryBase, + loaded.tableBase, + loaded.forkBufAddr ?? 0, + loaded.tlsBase ?? 0, + ); } return handle; + } finally { + releaseMainDlopenLock(); + } }, __wasm_dlsym: (handle: number, namePtr: number, nameLen: number): number => { @@ -428,7 +782,8 @@ function buildDlopenImports( }, __wasm_dlerror: (bufPtr: number, bufMax: number): number => { - const err = getLinker().dlerror(); + const err = hostDlopenError ?? getLinker().dlerror(); + hostDlopenError = null; if (!err) return 0; const encoded = encoder.encode(err); const len = Math.min(encoded.length, bufMax); @@ -437,7 +792,14 @@ function buildDlopenImports( }, }; - return { imports, replayDlopens }; + return { + imports, + replayDlopens, + completeSideModuleForkUnwind, + beginSideModuleForkRewind, + assertNoActiveSideModuleFork, + resetForkChildLock, + }; } /** @@ -451,6 +813,13 @@ function buildImportObject( dlopenImports?: Record, getInstance?: () => WebAssembly.Instance | undefined, ptrWidth: 4 | 8 = 4, + longjmpTag?: WebAssembly.Tag, + cppExceptionTag?: WebAssembly.Tag, + postVmInterruptTimer?: ( + timedOutPtr: number, + vmInterruptPtr: number, + seconds: number, + ) => void, ): WebAssembly.Imports { const envImports: Record = { memory }; /** Convert wasm64 BigInt pointer to number (safe since addresses < 4GB) */ @@ -470,14 +839,21 @@ function buildImportObject( } } - // llvm/lld ≥22 emit __c_longjmp as a tag import for setjmp users; instantiation fails silently without it. + // LLVM/lld >= 22 import this tag for setjmp users. The process owns its + // identity so a longjmp thrown through a side module can be caught by the + // main image (and vice versa). if (moduleImports.some(i => i.module === "env" && i.name === "__c_longjmp" && (i.kind as string) === "tag")) { - const Tag = (WebAssembly as typeof WebAssembly & { - Tag?: new (descriptor: { parameters: string[] }) => WebAssembly.Tag; - }).Tag; - if (Tag) { - envImports.__c_longjmp = new Tag({ parameters: ["i32"] }) as unknown as WebAssembly.ExportValue; - } + envImports.__c_longjmp = requireLongjmpTag( + longjmpTag, + "process module", + ) as unknown as WebAssembly.ExportValue; + } + + if (moduleImports.some(i => i.module === "env" && i.name === "__cpp_exception" && (i.kind as string) === "tag")) { + envImports.__cpp_exception = requireCppExceptionTag( + cppExceptionTag, + "process module", + ) as unknown as WebAssembly.ExportValue; } // Add dlopen imports if provided @@ -485,6 +861,26 @@ function buildImportObject( Object.assign(envImports, dlopenImports); } + if ( + moduleImports.some( + (i) => + i.module === "env" && + i.name === "__wasm_posix_vm_interrupt_after" && + i.kind === "function", + ) + ) { + if (!postVmInterruptTimer) { + throw new Error("VM interrupt timer import requested without a host timer route"); + } + envImports.__wasm_posix_vm_interrupt_after = ( + timedOutPtr: number | bigint, + vmInterruptPtr: number | bigint, + seconds: number | bigint, + ): void => { + postVmInterruptTimer(n(timedOutPtr), n(vmInterruptPtr), n(seconds)); + }; + } + // C++ operator new/delete fallbacks — delegate to the wasm instance's malloc/free. // Normally resolved by MariaDB's my_new.cc (USE_MYSYS_NEW), but kept as safety net. if (getInstance) { @@ -688,14 +1084,109 @@ function buildImportObject( /** Size of the fork save buffer used by wpk_fork_* instrumentation */ const FORK_BUF_SIZE = FORK_SAVE_BUFFER_SIZE; -// Slot below forkBufAddr that stores the head pointer of the dlopen -// archive linked list. Fork's memcpy carries the parent's archive into -// the child intact; the child walks it to replay each dlopen before -// wpk_fork rewind. +/** + * Detect a fork-continuation save-buffer overrun after an unwind completes. + * + * The instrumentation keeps `current_pos` — the pointer-width integer at the + * base of the save buffer (`forkBufAddr + 0`) — seeded to the absolute address + * `forkBufAddr + frames_start_offset` and advanced by every saved frame. After + * unwind it is therefore the high-water linear-memory address written (see + * crates/fork-instrument/src/runtime.rs, `emit_unwind_begin`). Main-process and + * pthread buffers sit below their syscall channels; fork-capable side modules + * use independent allocations. The explicit `forkBufSize` keeps the same + * bounds check truthful for either placement. Frames grow upward, away from + * the header, so the base word holding `current_pos` stays readable here. + * + * The instrumented unwind carries no bounds check of its own — runtime.rs + * documents the requirement `frames_start_offset + Σframe ≤ buffer_size` but + * never enforces it. Without this host check the overrun is silent: it + * corrupts the channel and only surfaces later as an unexplained trap or a + * fork child that never makes progress. Returns the overrun in bytes, or 0 + * when the save fit within the buffer. + */ +export function forkSaveBufferOverrun( + memory: WebAssembly.Memory, + forkBufAddr: number, + ptrWidth: 4 | 8, + forkBufSize: number, +): number { + const view = new DataView(memory.buffer); + const currentPos = ptrWidth === 8 + ? Number(view.getBigUint64(forkBufAddr, true)) + : view.getUint32(forkBufAddr, true); + const bufferEnd = forkBufAddr + forkBufSize; + return currentPos > bufferEnd ? currentPos - bufferEnd : 0; +} + +/** + * Finish the active side-module unwind and reject an overrun before the main + * worker is allowed to send SYS_FORK. Side modules own a save-buffer + * allocation separate from the main process channel, so checking only the + * main buffer cannot protect this continuation. + */ +export function finalizeSideModuleForkUnwind( + memory: WebAssembly.Memory, + state: SideModuleForkState, + ptrWidth: 4 | 8, +): void { + const sideForkState = (): number => + Number((state.instance.exports.wpk_fork_state as () => number)()); + if (sideForkState() !== 1) { + throw new Error(`${state.name}: expected UNWINDING before side-module unwind completion`); + } + (state.instance.exports.wpk_fork_unwind_end as () => void)(); + if (sideForkState() !== 0) { + throw new Error(`${state.name}: side-module unwind did not return to NORMAL`); + } + + const overrun = forkSaveBufferOverrun( + memory, + state.forkBufAddr, + ptrWidth, + state.forkBufSize, + ); + if (overrun > 0) { + throw new Error( + `${state.name}: side-module fork() continuation save buffer overflow — ` + + `the call stack at fork() needed ${state.forkBufSize + overrun} bytes ` + + `but only ${state.forkBufSize} (FORK_SAVE_BUFFER_SIZE) are reserved; ` + + `the side-module stack is too deep/wide to fork here. This is a ` + + `platform limit of the fork continuation buffer, not a defect in the program.`, + ); + } +} + +// Host-private control slots below the process main channel's fork buffer. +// Fork's memcpy carries the parent's dlopen archive into the child intact; +// the child walks it to replay each module before wpk_fork rewind. These are +// intentionally not relative to a pthread's rewind buffer. const DLOPEN_HEAD_OFFSET_WASM32 = 12; const DLOPEN_HEAD_OFFSET_WASM64 = 24; -const DLOPEN_ENTRY_SIZE_WASM32 = 24; -const DLOPEN_ENTRY_SIZE_WASM64 = 48; +const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM32 = 16; +const DLOPEN_ACTIVE_SIDE_FORK_OFFSET_WASM64 = 32; +// Atomic host-private reader/writer arbitration between process-main dlopen +// and pthread fork. A negative value is the exclusive main-worker dlopen +// writer; a positive value counts concurrent pthread forks from their +// pre-unwind archive check through memory-copy/SYS_FORK and parent rewind. +// This preserves Kandelo's existing concurrent-pthread-fork behavior while +// preventing a new archive entry from racing any fork snapshot. A fork child +// clears its copied value before replay because its memory is independent. +const DLOPEN_LOCK_OFFSET_WASM32 = 20; +const DLOPEN_LOCK_OFFSET_WASM64 = 40; +const DLOPEN_LOCK_IDLE = 0; +const DLOPEN_LOCK_WRITER = -1; +const DLOPEN_LOCK_MAX_READERS = 0x7fff_ffff; +// Each entry also carries the side module's instance-local TLS base. Fork +// copies the TLS bytes in memory, but a new replay instance's mutable global +// must be restored explicitly. Zero is the explicit no-TLS sentinel; TLS +// allocations are required to have a positive base. +// +// This is a host-private, transient replay record: the same host build writes +// and reads it around one fork, and neither guest code nor persisted package +// artifacts interpret the layout. Enlarging it therefore does not alter the +// guest/kernel ABI. The ABI classifier/check still guards the public contract. +const DLOPEN_ENTRY_SIZE_WASM32 = 40; +const DLOPEN_ENTRY_SIZE_WASM64 = 72; const WPK_FORK_EXPORTS = [ "wpk_fork_unwind_begin", @@ -865,6 +1356,8 @@ export async function centralizedWorkerMain( } // --- SDK module path (existing) --- + const processLongjmpTag = createLongjmpTag(ptrWidth); + const processCppExceptionTag = createCppExceptionTag(ptrWidth); let kernelExitStatus: number | null = null; const kernelImports = buildKernelImports( memory, @@ -878,9 +1371,15 @@ export async function centralizedWorkerMain( // and reject stale legacy fork artifacts before they can run. const moduleExports = WebAssembly.Module.exports(module); const hasForkInstrumentation = hasCompleteForkInstrumentation(moduleExports, pid); + const forkCapabilityClaim = readForkInstrumentCapabilityClaim(module); + const hasDylinkForkRole = forkInstrumentRoleAvailable( + forkCapabilityClaim, + FORK_CAP_DYLINK_MAIN, + ); // Fork state — captured by kernel_fork closure let forkResult = 0; const forkBufAddr = initData.forkBufAddr ?? channelOffset - FORK_BUF_SIZE; + const dlopenArchiveControlAddr = channelOffset - FORK_BUF_SIZE; if (hasForkInstrumentation) { // Override kernel_fork with fork-instrumentation-aware version. @@ -911,16 +1410,31 @@ export async function centralizedWorkerMain( const dlopenSupport = buildDlopenImports( memory, channelOffset, - forkBufAddr, + dlopenArchiveControlAddr, () => processInstance?.exports.__indirect_function_table as WebAssembly.Table | undefined, () => processInstance?.exports.__stack_pointer as WebAssembly.Global | undefined, () => processInstance ?? undefined, ptrWidth, + processLongjmpTag, + processCppExceptionTag, + hasDylinkForkRole, ); const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, - () => processInstance ?? undefined, ptrWidth); + () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, + (timedOutPtr, vmInterruptPtr, seconds) => { + port.postMessage({ + type: "vm_interrupt_timer", + pid, + timedOutPtr, + vmInterruptPtr, + seconds, + } satisfies WorkerToHostMessage); + }); const instance = await WebAssembly.instantiate(module, importObject); processInstance = instance; + if (initData.isForkChild) { + dlopenSupport.resetForkChildLock(); + } verifyProgramAbi(programBytes, initData.kernelAbiVersion, pid); // For the fork-parent case (initial launch, not a fork child), install @@ -1001,6 +1515,7 @@ export async function centralizedWorkerMain( } replayedForkChildDlopens = true; } + dlopenSupport.beginSideModuleForkRewind(); needsRewind = false; } @@ -1021,6 +1536,33 @@ export async function centralizedWorkerMain( // Unwind completed (fork) — finalize and send SYS_FORK. unwindEnd(); + // The unwind writes saved frames into a fixed FORK_BUF_SIZE buffer + // that abuts the syscall channel. If the call stack at fork() was + // too deep/wide, those writes overran into the channel. Fail + // truthfully here rather than sending a fork on a corrupt channel + // and spawning a child whose continuation buffer is already + // clobbered (which otherwise surfaces as an unexplained trap or a + // child worker that never makes progress). The process is torn + // down after this throw, discarding the corrupted channel. + const overrun = forkSaveBufferOverrun( + memory, + forkBufAddr, + ptrWidth, + FORK_BUF_SIZE, + ); + if (overrun > 0) { + throw new Error( + `pid=${pid}: fork() continuation save buffer overflow — the ` + + `call stack at fork() needed ${FORK_BUF_SIZE + overrun} bytes ` + + `but only ${FORK_BUF_SIZE} (FORK_SAVE_BUFFER_SIZE) are ` + + `reserved; the stack is too deep/wide to fork here. This is a ` + + `platform limit of the fork continuation buffer, not a defect ` + + `in the program.`, + ); + } + + dlopenSupport.completeSideModuleForkUnwind(); + // Send SYS_FORK through the channel now that memory has the // fork save buffer populated (saved_globals + frames). const childPid = sendForkSyscall(memory, channelOffset); @@ -1033,6 +1575,7 @@ export async function centralizedWorkerMain( } // Normal return — program finished + dlopenSupport.assertNoActiveSideModuleFork(); if (kernelExitStatus === null) { kernelImports.kernel_exit(0); exitCode = kernelExitStatus ?? 0; @@ -1063,14 +1606,26 @@ export async function centralizedWorkerMain( const dlopenSupport = buildDlopenImports( memory, channelOffset, - forkBufAddr, + dlopenArchiveControlAddr, () => processInstance?.exports.__indirect_function_table as WebAssembly.Table | undefined, () => processInstance?.exports.__stack_pointer as WebAssembly.Global | undefined, () => processInstance ?? undefined, ptrWidth, + processLongjmpTag, + processCppExceptionTag, + false, ); const importObject = buildImportObject(module, memory, kernelImports, channelOffset, dlopenSupport.imports, - () => processInstance ?? undefined, ptrWidth); + () => processInstance ?? undefined, ptrWidth, processLongjmpTag, processCppExceptionTag, + (timedOutPtr, vmInterruptPtr, seconds) => { + port.postMessage({ + type: "vm_interrupt_timer", + pid, + timedOutPtr, + vmInterruptPtr, + seconds, + } satisfies WorkerToHostMessage); + }); const instance = await WebAssembly.instantiate(module, importObject); processInstance = instance; verifyProgramAbi(programBytes, initData.kernelAbiVersion, pid); @@ -1709,11 +2264,69 @@ export async function centralizedThreadWorkerMain( port: MessagePort, initData: CentralizedThreadInitMessage, ): Promise { - const { memory, channelOffset, pid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr } = initData; + const { + memory, + processChannelOffset, + channelOffset, + pid, + tid, + fnPtr, + argPtr, + stackPtr, + tlsPtr, + ctidPtr, + } = initData; const tlsOffset = initData.tlsOffset ?? initData.tlsAllocAddr; const ptrWidth = initData.ptrWidth ?? 4; let threadInstance: WebAssembly.Instance | undefined; + let processDlopenLock: Int32Array | undefined; + let pthreadForkLockHeld = false; + const acquirePthreadForkLock = (): boolean => { + if (!processDlopenLock) { + throw new Error(`pid=${pid} tid=${tid}: missing process dlopen lock`); + } + if (pthreadForkLockHeld) { + throw new Error(`pid=${pid} tid=${tid}: pthread fork lock already held`); + } + for (;;) { + const owner = Atomics.load(processDlopenLock, 0); + if (owner < DLOPEN_LOCK_IDLE) return false; + if (owner >= DLOPEN_LOCK_MAX_READERS) { + throw new Error( + `pid=${pid} tid=${tid}: process dlopen lock reader overflow`, + ); + } + if ( + Atomics.compareExchange(processDlopenLock, 0, owner, owner + 1) + === owner + ) { + pthreadForkLockHeld = true; + return true; + } + } + }; + const releasePthreadForkLock = (): void => { + if (!pthreadForkLockHeld || !processDlopenLock) return; + for (;;) { + const owner = Atomics.load(processDlopenLock, 0); + if (owner <= DLOPEN_LOCK_IDLE) { + pthreadForkLockHeld = false; + throw new Error( + `pid=${pid} tid=${tid}: pthread fork lost reader ownership ` + + `(state=${owner})`, + ); + } + if ( + Atomics.compareExchange(processDlopenLock, 0, owner, owner - 1) + === owner + ) { + pthreadForkLockHeld = false; + if (owner === 1) Atomics.notify(processDlopenLock, 0); + return; + } + } + }; try { // Strip the start section AND neuter the constructor function body to prevent @@ -1730,6 +2343,37 @@ export async function centralizedThreadWorkerMain( const moduleExports = WebAssembly.Module.exports(module); const hasForkInstrumentation = hasCompleteForkInstrumentation(moduleExports, pid); const forkBufAddr = channelOffset - FORK_BUF_SIZE; + const processArchiveHeadOffset = ptrWidth === 8 + ? DLOPEN_HEAD_OFFSET_WASM64 + : DLOPEN_HEAD_OFFSET_WASM32; + const processArchiveHeadAddr = processChannelOffset + - FORK_BUF_SIZE + - processArchiveHeadOffset; + const processArchiveLockOffset = ptrWidth === 8 + ? DLOPEN_LOCK_OFFSET_WASM64 + : DLOPEN_LOCK_OFFSET_WASM32; + const processArchiveLockAddr = processChannelOffset + - FORK_BUF_SIZE + - processArchiveLockOffset; + if ( + !Number.isSafeInteger(processArchiveHeadAddr) + || processArchiveHeadAddr <= 0 + || processArchiveHeadAddr + ptrWidth > memory.buffer.byteLength + || !Number.isSafeInteger(processArchiveLockAddr) + || processArchiveLockAddr <= 0 + || processArchiveLockAddr + 4 > memory.buffer.byteLength + ) { + throw new Error( + `pid=${pid} tid=${tid}: invalid process dlopen archive anchor ` + + `${String(processArchiveHeadAddr)}`, + ); + } + processDlopenLock = new Int32Array(memory.buffer, processArchiveLockAddr, 1); + const processHasDlopenArchive = (): boolean => { + return ptrWidth === 8 + ? Atomics.load(new BigUint64Array(memory.buffer, processArchiveHeadAddr, 1), 0) !== 0n + : Atomics.load(new Uint32Array(memory.buffer, processArchiveHeadAddr, 1), 0) !== 0; + }; let forkResult = 0; let kernelThreadExitStatus: number | null = null; @@ -1749,15 +2393,38 @@ export async function centralizedThreadWorkerMain( const getState = threadInstance.exports.wpk_fork_state as () => number; const state = getState(); if (state === 2) { - (threadInstance.exports.wpk_fork_rewind_end as () => void)(); + try { + (threadInstance.exports.wpk_fork_rewind_end as () => void)(); + } finally { + releasePthreadForkLock(); + } return forkResult; } - (threadInstance.exports.wpk_fork_unwind_begin as (addr: number) => void)(forkBufAddr); + // Side modules live in the process main worker's module/table/tag + // graph. A pthread worker cannot replay that graph into its own + // instance, so fork must fail before unwind once the process has ever + // loaded a side module. The head is read live from shared memory so a + // dlopen after pthread creation is still observed. + if (!acquirePthreadForkLock()) { + return -95; // ENOTSUP: process-main dlopen is active + } + if (processHasDlopenArchive()) { + releasePthreadForkLock(); + return -95; // ENOTSUP: pthreads cannot replay process side modules + } + + try { + (threadInstance.exports.wpk_fork_unwind_begin as (addr: number) => void)(forkBufAddr); + } catch (error) { + releasePthreadForkLock(); + throw error; + } return 0; }; } else { kernelImports.kernel_fork = (): number => { + if (processHasDlopenArchive()) return -95; // ENOTSUP throw new Error( `pid=${pid} tid=${tid}: kernel_fork reached without complete ` + "wasm-fork-instrument exports. Rebuild the program with " + @@ -1765,8 +2432,20 @@ export async function centralizedThreadWorkerMain( ); }; } - const importObject = buildImportObject(module, memory, kernelImports, channelOffset, undefined, - () => threadInstance, ptrWidth); + const threadLongjmpTag = createLongjmpTag(ptrWidth); + const threadCppExceptionTag = createCppExceptionTag(ptrWidth); + const threadDlopenImports = buildUnsupportedThreadDlopenImports(memory); + const importObject = buildImportObject(module, memory, kernelImports, channelOffset, threadDlopenImports, + () => threadInstance, ptrWidth, threadLongjmpTag, threadCppExceptionTag, + (timedOutPtr, vmInterruptPtr, seconds) => { + port.postMessage({ + type: "vm_interrupt_timer", + pid, + timedOutPtr, + vmInterruptPtr, + seconds, + } satisfies WorkerToHostMessage); + }); const instance = new WebAssembly.Instance(module, importObject); threadInstance = instance; @@ -1840,8 +2519,34 @@ export async function centralizedThreadWorkerMain( const forkState = getState(); if (forkState === 1) { unwindEnd(); + // See the main-process fork path: a too-deep/wide stack overruns the + // fixed save buffer into the channel. Detect and fail truthfully. + const overrun = forkSaveBufferOverrun( + memory, + forkBufAddr, + ptrWidth, + FORK_BUF_SIZE, + ); + if (overrun > 0) { + throw new Error( + `pid=${pid} tid=${tid}: fork() continuation save buffer ` + + `overflow — the call stack at fork() needed ` + + `${FORK_BUF_SIZE + overrun} bytes but only ${FORK_BUF_SIZE} ` + + `(FORK_SAVE_BUFFER_SIZE) are reserved; too deep/wide to fork ` + + `from this thread. Platform limit, not a program defect.`, + ); + } + // Close the race where the process main worker dlopens after this + // pthread began unwinding but before it completed. Rewind locally + // with ENOTSUP and do not create a child. + if (processHasDlopenArchive()) { + forkResult = -95; + needsRewind = true; + continue; + } const childPid = sendForkSyscall(memory, channelOffset); if (childPid < 0) { + releasePthreadForkLock(); throw new Error(`Fork failed: errno=${-childPid}`); } forkResult = childPid; @@ -1867,10 +2572,18 @@ export async function centralizedThreadWorkerMain( } } - // Send SYS_EXIT through the channel. The kernel worker performs - // CLONE_CHILD_CLEARTID after it observes SYS_EXIT; doing it here would - // let pthread_join reclaim the stack while this Worker is still running. - { + // A well-formed fork releases its reader token from the state=2 import + // above. Keep normal-return cleanup defensive so an unexpected + // instrumenter state cannot strand the process-wide writer lock. + releasePthreadForkLock(); + + // A normal return has not passed through libc's noreturn kernel_exit + // import, so publish SYS_EXIT here. When kernel_exit already ran it sent + // and completed SYS_EXIT before the compiler's trailing unreachable was + // caught above. Publishing a second exit on that now-removed channel + // parks this Worker forever; after slot reuse its stale atomic waiter can + // steal the next pthread's first notify. + if (kernelThreadExitStatus === null) { const view = new DataView(memory.buffer); const base = channelOffset; view.setInt32(base + CH_SYSCALL, ABI_SYSCALLS.Exit, true); @@ -1895,6 +2608,7 @@ export async function centralizedThreadWorkerMain( tid, } satisfies WorkerToHostMessage); } catch (err) { + releasePthreadForkLock(); const message = err instanceof Error ? `${err.message}\n${err.stack ?? ""}` : String(err); diff --git a/host/src/worker-protocol.ts b/host/src/worker-protocol.ts index b7c4b127cd..2b80c6359b 100644 --- a/host/src/worker-protocol.ts +++ b/host/src/worker-protocol.ts @@ -70,6 +70,9 @@ export interface CentralizedThreadInitMessage { programBytes: ArrayBuffer; programModule?: WebAssembly.Module; memory: WebAssembly.Memory; + /** Main process channel offset. The thread reads the process-wide dlopen + * archive head relative to this live shared-memory anchor before fork. */ + processChannelOffset: number; channelOffset: number; fnPtr: number; argPtr: number; @@ -99,7 +102,8 @@ export type WorkerToHostMessage = | WorkerErrorMessage | ExecRequestMessage | ExecCompleteMessage - | AlarmSetMessage; + | AlarmSetMessage + | VmInterruptTimerMessage; export interface WorkerReadyMessage { type: "ready"; @@ -141,6 +145,14 @@ export interface AlarmSetMessage { seconds: number; } +export interface VmInterruptTimerMessage { + type: "vm_interrupt_timer"; + pid: number; + timedOutPtr: number; + vmInterruptPtr: number; + seconds: number; +} + export interface ExecReplyMessage { type: "exec_reply"; wasmBytes: ArrayBuffer; diff --git a/host/test/abi-version.test.ts b/host/test/abi-version.test.ts index 8fd46b4d16..3169078314 100644 --- a/host/test/abi-version.test.ts +++ b/host/test/abi-version.test.ts @@ -27,9 +27,8 @@ describe("ABI version marker", () => { bytes: Uint8Array, ): Promise { const ptrWidth = detectPtrWidth(toArrayBuffer(bytes)); - // Match host/src/kernel.ts. The kernel wasm grows when we add - // synthetic data (e.g. /etc/ssl/cert.pem); 24 pages keeps headroom - // without re-tuning per change. + // Match host/src/kernel.ts. Keep headroom above the kernel Wasm's + // linker-derived minimum without re-tuning this test per change. const memory = ptrWidth === 8 ? new WebAssembly.Memory({ initial: 24n, diff --git a/host/test/binary-resolver.test.ts b/host/test/binary-resolver.test.ts index c0444c53bf..ce77e9de3f 100644 --- a/host/test/binary-resolver.test.ts +++ b/host/test/binary-resolver.test.ts @@ -7,6 +7,7 @@ import { binariesDir, localBinariesDir, resolveBinary, + tryResolveBinarySet, } from "../src/binary-resolver"; import { ABI_VERSION } from "../src/generated/abi"; import { @@ -104,7 +105,7 @@ async function vfsImage( return compressed ? new Uint8Array(zstdCompressSync(image)) : image; } -function fixtureRelPath(extension: ".wasm" | ".vfs" | ".vfs.zst"): string { +function fixtureClosureRelPaths(names: readonly string[]): string[] { const testRoot = "programs/wasm32/__binary_resolver_test__"; const dir = `${testRoot}/${randomUUID()}`; cleanupDirs.add(join(localBinariesDir(), dir)); @@ -115,7 +116,11 @@ function fixtureRelPath(extension: ".wasm" | ".vfs" | ".vfs.zst"): string { cleanupEmptyDirs.add(join(root, "programs")); cleanupEmptyDirs.add(root); } - return `${dir}/artifact${extension}`; + return names.map((name) => `${dir}/${name}`); +} + +function fixtureRelPath(extension: ".wasm" | ".vfs" | ".vfs.zst" | ".dat"): string { + return fixtureClosureRelPaths([`artifact${extension}`])[0]; } function candidatePath(root: string, relPath: string): string { @@ -190,4 +195,136 @@ describe("binary resolver artifact policy", () => { expect(resolveBinary(relPath)).toBe(fetchedPath); }); + + it("prefers a local declared runtime data file over the fetched candidate", () => { + const relPath = fixtureRelPath(".dat"); + const localPath = writeCandidate( + localBinariesDir(), + relPath, + new TextEncoder().encode("local-runtime"), + ); + writeCandidate( + binariesDir(), + relPath, + new TextEncoder().encode("fetched-runtime"), + ); + + expect(resolveBinary(relPath)).toBe(localPath); + }); +}); + +describe("binary resolver package closures", () => { + it("returns a complete local closure from one provenance root", () => { + const [wasmRel, dataRel] = fixtureClosureRelPaths([ + "program.wasm", + "runtime.dat", + ]); + const wasmPath = writeCandidate( + localBinariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION), + ); + const dataPath = writeCandidate( + localBinariesDir(), + dataRel, + new TextEncoder().encode("local-runtime"), + ); + writeCandidate( + binariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION), + ); + writeCandidate( + binariesDir(), + dataRel, + new TextEncoder().encode("fetched-runtime"), + ); + + expect(tryResolveBinarySet([wasmRel, dataRel])).toEqual([wasmPath, dataPath]); + }); + + it("falls back wholesale from a partial local closure to complete fetched bytes", () => { + const [wasmRel, dataRel] = fixtureClosureRelPaths([ + "program.wasm", + "runtime.dat", + ]); + writeCandidate( + localBinariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION), + ); + const fetchedWasm = writeCandidate( + binariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION), + ); + const fetchedData = writeCandidate( + binariesDir(), + dataRel, + new TextEncoder().encode("fetched-runtime"), + ); + + expect(tryResolveBinarySet([wasmRel, dataRel])).toEqual([ + fetchedWasm, + fetchedData, + ]); + }); + + it("rejects complementary partial tiers instead of mixing a closure", () => { + const [wasmRel, dataRel] = fixtureClosureRelPaths([ + "program.wasm", + "runtime.dat", + ]); + writeCandidate( + localBinariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION), + ); + writeCandidate( + binariesDir(), + dataRel, + new TextEncoder().encode("fetched-runtime"), + ); + + expect(() => tryResolveBinarySet([wasmRel, dataRel])).toThrow( + /no single provenance tier.*tiers will not be mixed/s, + ); + }); + + it("falls back wholesale when a local closure member fails artifact policy", () => { + const [wasmRel, dataRel] = fixtureClosureRelPaths([ + "program.wasm", + "runtime.dat", + ]); + writeCandidate( + localBinariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION - 1), + ); + writeCandidate( + localBinariesDir(), + dataRel, + new TextEncoder().encode("local-runtime"), + ); + const fetchedWasm = writeCandidate( + binariesDir(), + wasmRel, + executableWasmWithAbi(ABI_VERSION), + ); + const fetchedData = writeCandidate( + binariesDir(), + dataRel, + new TextEncoder().encode("fetched-runtime"), + ); + + expect(tryResolveBinarySet([wasmRel, dataRel])).toEqual([ + fetchedWasm, + fetchedData, + ]); + }); + + it("returns null only when no closure member exists in any tier", () => { + const relPaths = fixtureClosureRelPaths(["program.wasm", "runtime.dat"]); + expect(tryResolveBinarySet(relPaths)).toBeNull(); + }); }); diff --git a/host/test/browser-kernel.test.ts b/host/test/browser-kernel.test.ts index 178cfe1c25..439b0fa4d1 100644 --- a/host/test/browser-kernel.test.ts +++ b/host/test/browser-kernel.test.ts @@ -147,6 +147,161 @@ describe("BrowserKernel", () => { expect(await exit).toBe(7); }); + it("preserves a short spawnFromVfs exit that precedes the spawn response continuation", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await initPromise; + + const spawnPromise = kernel.spawnFromVfs("/bin/true", ["/bin/true"]); + await new Promise((resolve) => setTimeout(resolve, 0)); + const spawn = worker.lastMessage("spawn"); + worker.simulateMessage({ + type: "response", + requestId: spawn.requestId, + result: 101, + }); + // A tiny process can exit before the resolved request promise resumes and + // installs its pid-indexed waiter on the browser main thread. + worker.simulateMessage({ type: "exit", pid: 101, status: 0 }); + + const { pid, exit } = await spawnPromise; + expect(pid).toBe(101); + expect(await Promise.race([ + exit, + new Promise((resolve) => setTimeout(() => resolve(-999), 50)), + ])).toBe(0); + }); + + it("delivers host diagnostics without contaminating guest stderr", async () => { + const BrowserKernel = await loadBrowserKernel(); + const onHostDiagnostic = vi.fn(); + const onStderr = vi.fn(); + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + onHostDiagnostic, + onStderr, + }); + + const bootPromise = kernel.boot({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + argv: ["/init"], + }); + await new Promise((r) => setTimeout(r, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await new Promise((r) => setTimeout(r, 0)); + const spawn = worker.lastMessage("spawn"); + worker.simulateMessage({ + type: "response", + requestId: spawn.requestId, + result: 100, + }); + await bootPromise; + + worker.simulateMessage({ + type: "host_diagnostic", + pid: 100, + status: 7, + source: "kernel process exit", + message: "[kernel-worker] nonzero process exit pid=100 status=7", + }); + + expect(onHostDiagnostic).toHaveBeenCalledOnce(); + expect(onHostDiagnostic).toHaveBeenCalledWith({ + pid: 100, + status: 7, + source: "kernel process exit", + message: "[kernel-worker] nonzero process exit pid=100 status=7", + }); + expect(onStderr).not.toHaveBeenCalled(); + }); + + it("reports a worker-level error as a host diagnostic, not guest stderr", async () => { + const BrowserKernel = await loadBrowserKernel(); + const onHostDiagnostic = vi.fn(); + const onStderr = vi.fn(); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + onHostDiagnostic, + onStderr, + }); + + const bootPromise = kernel.boot({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + argv: ["/init"], + }); + await new Promise((r) => setTimeout(r, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await new Promise((r) => setTimeout(r, 0)); + const spawn = worker.lastMessage("spawn"); + worker.simulateMessage({ + type: "response", + requestId: spawn.requestId, + result: 100, + }); + await bootPromise; + + worker.onerror?.({ message: "worker crashed" }); + + expect(onHostDiagnostic).toHaveBeenCalledOnce(); + expect(onHostDiagnostic).toHaveBeenCalledWith({ + pid: 0, + source: "kernel worker", + message: "[BrowserKernel] kernel worker error: worker crashed", + }); + expect(onStderr).not.toHaveBeenCalled(); + expect(consoleError).toHaveBeenCalledWith( + "[BrowserKernel] kernel worker error: worker crashed", + ); + }); + + it("forwards posix_spawn parentage from the browser kernel worker", async () => { + const BrowserKernel = await loadBrowserKernel(); + const processEvents: Array<{ + kind: "spawn" | "exec" | "exit"; + pid: number; + ppid?: number; + exitStatus?: number; + }> = []; + const kernel = new BrowserKernel({ + kernelOwnedFs: true, + onProcessEvent: (event) => processEvents.push(event), + }); + + const bootPromise = kernel.boot({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + argv: ["/init"], + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const worker = MockWorker.instances[0]!; + worker.simulateMessage({ type: "ready" }); + await new Promise((resolve) => setTimeout(resolve, 0)); + const spawn = worker.lastMessage("spawn"); + worker.simulateMessage({ type: "response", requestId: spawn.requestId, result: 100 }); + await bootPromise; + + processEvents.length = 0; + worker.simulateMessage({ type: "proc_event", kind: "spawn", pid: 2, ppid: 100 }); + worker.simulateMessage({ type: "proc_event", kind: "exec", pid: 2 }); + + expect(processEvents).toEqual([ + { kind: "spawn", pid: 2, ppid: 100 }, + { kind: "exec", pid: 2 }, + ]); + }); + it("readFileFromVfs round-trips a path to the worker and back", async () => { const BrowserKernel = await loadBrowserKernel(); const kernel = new BrowserKernel({ kernelOwnedFs: true }); @@ -166,6 +321,62 @@ describe("BrowserKernel", () => { expect(await readPromise).toEqual(bytes); }); + it("mutates files through the VFS-owning worker with lossless snapshots", async () => { + const BrowserKernel = await loadBrowserKernel(); + const kernel = new BrowserKernel({ kernelOwnedFs: true }); + const initPromise = kernel.initFromImage({ + kernelWasm: new ArrayBuffer(8), + vfsImage: new Uint8Array(0), + }); + await new Promise((r) => setTimeout(r, 0)); + const w = MockWorker.instances[0]!; + w.simulateMessage({ type: "ready" }); + await initPromise; + + const original = new Uint8Array([9, 8, 7]); + const writePromise = kernel.writeFileToVfs("/php-src/generated.php", original, 0o640); + await new Promise((r) => setTimeout(r, 0)); + const write = w.lastMessage("write_vfs_file"); + expect(write).toMatchObject({ + path: "/php-src/generated.php", + mode: 0o640, + }); + expect(write.data).toEqual(original); + expect(write.data).not.toBe(original); + w.simulateMessage({ + type: "response", + requestId: write.requestId, + result: true, + }); + await writePromise; + + const snapshotPromise = kernel.readFileSnapshotFromVfs("/php-src/generated.php"); + await new Promise((r) => setTimeout(r, 0)); + const read = w.lastMessage("read_vfs_file"); + expect(read).toMatchObject({ + path: "/php-src/generated.php", + includeMode: true, + }); + const snapshot = { data: new Uint8Array([1, 2]), mode: 0o751 }; + w.simulateMessage({ + type: "response", + requestId: read.requestId, + result: snapshot, + }); + expect(await snapshotPromise).toEqual(snapshot); + + const unlinkPromise = kernel.unlinkFileFromVfs("/php-src/generated.php"); + await new Promise((r) => setTimeout(r, 0)); + const unlink = w.lastMessage("unlink_vfs_file"); + expect(unlink.path).toBe("/php-src/generated.php"); + w.simulateMessage({ + type: "response", + requestId: unlink.requestId, + result: true, + }); + expect(await unlinkPromise).toBe(true); + }); + describe("fetchInKernel", () => { async function bootedKernel() { const BrowserKernel = await loadBrowserKernel(); diff --git a/host/test/centralized-spawn.test.ts b/host/test/centralized-spawn.test.ts index ee078c5b92..bd941c1886 100644 --- a/host/test/centralized-spawn.test.ts +++ b/host/test/centralized-spawn.test.ts @@ -26,6 +26,12 @@ const spawnPauseWasm = join(repoRoot, "examples", "spawn-pause.wasm"); describe("non-forking posix_spawn", () => { it("runs spawn-smoke and the parent's fork_count stays 0", async () => { + const processEvents: Array<{ + kind: "spawn" | "exec" | "exit"; + pid: number; + ppid?: number; + exitStatus?: number; + }> = []; // Spawn a child program that lives in examples/ — keeps the test free // of the binaries-cache fetch. spawn-smoke takes the child path as // argv[1] and just exec-equivalents it via posix_spawn + waitpid. @@ -38,6 +44,7 @@ describe("non-forking posix_spawn", () => { useDefaultRootfs: false, timeout: 30_000, captureForkCount: true, + onProcessEvent: (event) => processEvents.push(event), }); expect(result.exitCode).toBe(0); @@ -48,6 +55,11 @@ describe("non-forking posix_spawn", () => { // GUARDRAIL: spawn must not increment the parent's fork counter. // A non-zero value here means SYS_SPAWN silently fell back to fork. expect(result.forkCount).toBe(0n); + + const rootSpawn = processEvents.find((event) => event.kind === "spawn" && event.ppid === undefined); + const childSpawn = processEvents.find((event) => event.kind === "spawn" && event.ppid !== undefined); + expect(rootSpawn).toBeDefined(); + expect(childSpawn?.ppid).toBe(rootSpawn?.pid); }); it("covers spawnp / file actions / SETPGROUP", async () => { @@ -100,4 +112,36 @@ describe("non-forking posix_spawn", () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + it("reports ENOEXEC for malformed Wasm before creating the spawn child", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "kandelo-malformed-wasm-")); + const malformedWasm = join(tempDir, "malformed.wasm"); + try { + // Valid magic/version, followed by a truncated type section. A magic-only + // check accepts this; WebAssembly compilation must reject it in spawn's + // side-effect-free preflight, before the kernel applies file actions. + writeFileSync(malformedWasm, Buffer.from([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x01, 0xff, + ])); + + const result = await runCentralizedProgram({ + programPath: spawnSmokeWasm, + argv: ["spawn-smoke", "/usr/bin/malformed.wasm"], + execPrograms: new Map([ + ["/usr/bin/malformed.wasm", malformedWasm], + ]), + useDefaultRootfs: false, + timeout: 30_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain("Exec format error"); + expect(result.stderr).not.toContain("Centralized worker failed"); + expect(result.stderr).not.toContain("WebAssembly.compile()"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/host/test/centralized-test-helper.ts b/host/test/centralized-test-helper.ts index ae886efb32..205d76ba81 100644 --- a/host/test/centralized-test-helper.ts +++ b/host/test/centralized-test-helper.ts @@ -21,12 +21,14 @@ import { type ProcessMemoryLayout, } from "../src/process-memory"; import { NodeKernelHost } from "../src/node-kernel-host"; +import type { HostDiagnostic } from "../src/host-diagnostic"; import type { CentralizedWorkerInitMessage, CentralizedThreadInitMessage, WorkerToHostMessage } from "../src/worker-protocol"; import type { PlatformIO } from "../src/types"; const __dirname = dirname(fileURLToPath(import.meta.url)); const MAX_PAGES = 16384; +const SIGSEGV = 11; const CH_TOTAL_SIZE = 72 + 65536; function createSharedProcessMemory( @@ -138,12 +140,24 @@ export interface RunProgramOptions { captureForkCount?: boolean; /** Use the canonical rootfs image in worker-thread mode. Defaults to true. */ useDefaultRootfs?: boolean; + /** Exact VFS image for tests that stage package runtime files. Overrides + * `useDefaultRootfs`; omitted means the canonical image. */ + rootfsImage?: "default" | ArrayBuffer | Uint8Array; + /** Observe process lifecycle events emitted by NodeKernelHost. Worker-thread mode only. */ + onProcessEvent?: (event: { + kind: "spawn" | "exec" | "exit"; + pid: number; + ppid?: number; + exitStatus?: number; + }) => void; } export interface RunProgramResult { exitCode: number; stdout: string; stderr: string; + /** Host-owned lifecycle/protocol diagnostics, never guest fd 2 bytes. */ + hostDiagnostics: HostDiagnostic[]; /** Raw stdout bytes (for binary output like compressed data) */ stdoutBytes: Uint8Array; /** Per-process fork counter for the spawned process, captured immediately @@ -178,6 +192,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { stdout += new TextDecoder().decode(data); @@ -215,6 +231,10 @@ async function runInWorkerThread(options: RunProgramOptions): Promise { stderr += new TextDecoder().decode(data); }, + onHostDiagnostic: (diagnostic) => { + hostDiagnostics.push(diagnostic); + }, + onProcessEvent: options.onProcessEvent, }); await host.init(); @@ -269,7 +289,7 @@ async function runInWorkerThread(options: RunProgramOptions): Promise(); const processPtrWidths = new Map(); const forkReplayContexts = new Map(); + let mainThreadForkCount: bigint | undefined; const pid = 100; @@ -337,6 +358,7 @@ async function runOnMainThread(options: RunProgramOptions): Promise { - kernelWorker.unregisterProcess(childPid); + const finalizeChildWorkerError = (reason: unknown): void => { + // Match the production hosts: an unexpected worker failure is a + // signal-style process death, not an unregister that makes the + // child disappear while its parent remains blocked in waitpid(). + // The worker identity guard also prevents a late event from an old + // generation tearing down a replacement process after exec. + if (workers.get(childPid) !== childWorker) return; + const message = reason instanceof Error ? reason.message : String(reason); + stderr += `[fork child ${childPid}] ${message}\n`; + try { kernelWorker.notifyHostProcessCrashed(childPid, SIGSEGV); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(childPid); } catch { /* best-effort */ } workers.delete(childPid); + processProgramBytes.delete(childPid); processLayouts.delete(childPid); threadAllocators.delete(childPid); processPtrWidths.delete(childPid); forkReplayContexts.delete(childPid); + childWorker.terminate().catch(() => {}); + }; + childWorker.on("error", finalizeChildWorkerError); + childWorker.on("message", (msg: unknown) => { + const m = msg as WorkerToHostMessage; + if (m.type === "error" && m.pid === childPid) { + finalizeChildWorkerError(m.message); + } }); return [childChannelOffset]; }, - onExec: async (execPid, path, argv, envp) => { + onExec: async (execPid, path, argv, envp, callerTid) => { const wasmPath = options.execPrograms?.get(path); if (!wasmPath) return -2; + if (!kernelWorker.supportsExecMetadataReplacement()) return -38; const newProgramBytes = loadProgramWasm(wasmPath); const newPtrWidth = detectPtrWidth(newProgramBytes); - - const setupResult = kernelWorker.kernelExecSetup(execPid); - if (setupResult < 0) return setupResult; - - kernelWorker.prepareProcessForExec(execPid); - - const oldWorker = workers.get(execPid); - if (oldWorker) { - await oldWorker.terminate().catch(() => {}); - workers.delete(execPid); - } + const sourcePtrWidth = processPtrWidths.get(execPid) ?? newPtrWidth; + const metadataResult = kernelWorker.validateExecMetadata(argv, envp, sourcePtrWidth); + if (metadataResult < 0) return metadataResult; const { memory: newMemory, @@ -421,45 +454,101 @@ async function runOnMainThread(options: RunProgramOptions): Promise { - console.error(`[exec] worker error for pid ${execPid}:`, err); - }); + const prepareResult = kernelWorker.kernelExecPrepare(execPid, callerTid); + if (prepareResult < 0) return prepareResult; + const addressSpaceResult = kernelWorker.prepareAddressSpaceForExec(execPid); + if (addressSpaceResult < 0) return addressSpaceResult; + let replacementWorker: ReturnType | undefined; + try { + const setupResult = kernelWorker.kernelExecSetup(execPid, callerTid); + if (setupResult < 0) return setupResult; + kernelWorker.prepareProcessForExec(execPid); + + const finalizeResult = kernelWorker.finalizeAddressSpaceForExec(execPid); + if (finalizeResult < 0) { + throw new Error("failed to detach the discarded address space"); + } - return 0; + const oldWorker = workers.get(execPid); + if (oldWorker) { + await oldWorker.terminate().catch(() => {}); + workers.delete(execPid); + } + if (kernelWorker.finalizeExecHandoffTermination(execPid) > 0) return 0; + + kernelWorker.registerProcess(execPid, newMemory, [newChannelOffset], { + skipKernelCreate: true, + ptrWidth: newPtrWidth, + metadataPtrWidth: sourcePtrWidth, + brkBase: newLayout.brkBase, + mmapBase: newLayout.mmapBase, + maxAddr: newLayout.maxAddr, + argv, + env: envp, + }); + processProgramBytes.set(execPid, newProgramBytes); + processLayouts.set(execPid, newLayout); + threadAllocators.set(execPid, newThreadAllocator); + processPtrWidths.set(execPid, newPtrWidth); + forkReplayContexts.delete(execPid); + + const initData: CentralizedWorkerInitMessage = { + type: "centralized_init", + pid: execPid, + ppid: 0, + programBytes: newProgramBytes, + memory: newMemory, + channelOffset: newChannelOffset, + argv, + env: envp, + ptrWidth: newPtrWidth, + }; + + replacementWorker = workerAdapter.createWorker(initData); + workers.set(execPid, replacementWorker); + replacementWorker.on("error", (err: Error) => { + console.error(`[exec] worker error for pid ${execPid}:`, err); + }); + kernelWorker.finishProcessExecHandoff(execPid); + return 0; + } catch (err) { + try { kernelWorker.prepareProcessForExec(execPid); } catch { /* best-effort */ } + if (replacementWorker && workers.get(execPid) !== replacementWorker) { + await replacementWorker.terminate().catch(() => {}); + } + const currentWorker = workers.get(execPid); + if (currentWorker) { + await currentWorker.terminate().catch(() => {}); + workers.delete(execPid); + } + try { kernelWorker.notifyHostProcessCrashed(execPid, SIGSEGV); } catch { /* best-effort */ } + try { kernelWorker.deactivateProcess(execPid); } catch { /* best-effort */ } + processProgramBytes.delete(execPid); + processLayouts.delete(execPid); + threadAllocators.delete(execPid); + processPtrWidths.delete(execPid); + forkReplayContexts.delete(execPid); + const message = err instanceof Error ? err.message : String(err); + stderr += `[exec] post-commit transition failed: ${message}\n`; + if (execPid === pid) resolveExit(128 + SIGSEGV); + return 0; + } }, onClone: async (clonePid, tid, fnPtr, argPtr, stackPtr, tlsPtr, ctidPtr, memory) => { const threadAllocator = threadAllocators.get(clonePid); if (!threadAllocator) throw new Error(`Unknown thread allocator for pid ${clonePid}`); const clonePtrWidth = processPtrWidths.get(clonePid) ?? ptrWidth; + const processChannelOffset = processLayouts.get(clonePid)?.channelOffset; + if (processChannelOffset === undefined) { + throw new Error(`Unknown process channel for pid ${clonePid}`); + } const alloc = threadAllocator.allocate(memory); - kernelWorker.addChannel(clonePid, alloc.channelOffset, tid, fnPtr, argPtr); + try { + kernelWorker.addChannel(clonePid, alloc.channelOffset, tid, fnPtr, argPtr, memory); + } catch (err) { + threadAllocator.free(alloc.basePage); + throw err; + } const threadInitData: CentralizedThreadInitMessage = { type: "centralized_thread_init", @@ -467,12 +556,14 @@ async function runOnMainThread(options: RunProgramOptions): Promise { if (exitPid === pid) { + if (options.captureForkCount) { + mainThreadForkCount = kernelWorker.getForkCount(exitPid); + } kernelWorker.unregisterProcess(exitPid); processProgramBytes.delete(exitPid); processLayouts.delete(exitPid); @@ -622,5 +716,12 @@ async function runOnMainThread(options: RunProgramOptions): Promise { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("defers every batch-1 relisten through setImmediate, not queueMicrotask", () => { + const tasks = controlTaskQueues(); + const { worker, channel } = createScheduler(); + const listenOnChannel = vi.fn(); + worker.listenOnChannel = listenOnChannel; + + worker.relistenChannel(channel); + + expect(worker.relistenBatchSize).toBe(1); + expect(tasks.setImmediate).toHaveBeenCalledOnce(); + expect(tasks.queueMicrotask).not.toHaveBeenCalled(); + expect(listenOnChannel).not.toHaveBeenCalled(); + + tasks.runNextImmediate(); + expect(listenOnChannel).toHaveBeenCalledOnce(); + expect(listenOnChannel).toHaveBeenCalledWith(channel); + }); + + it("defers an already-pending batch-1 dispatch", () => { + const tasks = controlTaskQueues(); + const { worker, channel } = createScheduler(CHANNEL_STATUS_PENDING); + const handleSyscall = vi.fn(); + worker.handleSyscall = handleSyscall; + + worker.listenOnChannel(channel); + + expect(tasks.setImmediate).toHaveBeenCalledOnce(); + expect(tasks.queueMicrotask).not.toHaveBeenCalled(); + expect(handleSyscall).not.toHaveBeenCalled(); + + tasks.runNextImmediate(); + expect(handleSyscall).toHaveBeenCalledOnce(); + expect(handleSyscall).toHaveBeenCalledWith(channel); + }); + + it("arms Atomics.waitAsync for an idle channel instead of polling", async () => { + const tasks = controlTaskQueues(); + const { worker, channel } = createScheduler(CHANNEL_STATUS_IDLE); + let wake!: (value: "ok") => void; + const waited = new Promise<"ok">((resolve) => { + wake = resolve; + }); + const waitAsync = vi.spyOn(Atomics, "waitAsync").mockReturnValue({ + async: true, + value: waited, + } as any); + + worker.listenOnChannel(channel); + + expect(worker.usePolling).toBe(false); + expect(waitAsync).toHaveBeenCalledOnce(); + expect(waitAsync).toHaveBeenCalledWith( + channel.i32View, + CH_STATUS / Int32Array.BYTES_PER_ELEMENT, + CHANNEL_STATUS_IDLE, + ); + expect(tasks.setImmediate).not.toHaveBeenCalled(); + expect(tasks.queueMicrotask).not.toHaveBeenCalled(); + + const listenAgain = vi.fn(); + worker.listenOnChannel = listenAgain; + wake("ok"); + await waited; + await Promise.resolve(); + + expect(listenAgain).toHaveBeenCalledOnce(); + expect(listenAgain).toHaveBeenCalledWith(channel); + }); + + it("drops an already-pending dispatch when exec replaces its channel", () => { + const tasks = controlTaskQueues(); + const { worker, channel } = createScheduler(CHANNEL_STATUS_PENDING); + const handleSyscall = vi.fn(); + worker.handleSyscall = handleSyscall; + + worker.listenOnChannel(channel); + + const replacementMemory = createMemory(); + const replacement = createChannel(channel.pid, replacementMemory); + worker.processes.set(channel.pid, { + pid: channel.pid, + memory: replacementMemory, + channels: [replacement], + }); + worker.activeChannels = [replacement]; + tasks.runNextImmediate(); + + expect(handleSyscall).not.toHaveBeenCalled(); + }); + + it("makes a queued relisten a no-op after unregister", () => { + const tasks = controlTaskQueues(); + const { worker, channel } = createScheduler(CHANNEL_STATUS_IDLE); + const waitAsync = vi.spyOn(Atomics, "waitAsync"); + const handleSyscall = vi.fn(); + worker.handleSyscall = handleSyscall; + + worker.relistenChannel(channel); + worker.processes.delete(channel.pid); + worker.activeChannels = []; + tasks.runNextImmediate(); + + expect(waitAsync).not.toHaveBeenCalled(); + expect(handleSyscall).not.toHaveBeenCalled(); + }); +}); + +function createScheduler(status = CHANNEL_STATUS_IDLE): { + worker: any; + channel: any; +} { + const pid = 7; + const memory = createMemory(); + const channel = createChannel(pid, memory); + Atomics.store(channel.i32View, CH_STATUS / Int32Array.BYTES_PER_ELEMENT, status); + const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map([[pid, { pid, memory, channels: [channel] }]]), + activeChannels: [channel], + stoppedPids: new Set(), + parkedChannelCompletions: new Map(), + deferredStoppedChannels: new Map(), + usePolling: false, + relistenBatchSize: 1, + relistenCount: 0, + }); + return { worker, channel }; +} + +function createMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); +} + +function createChannel(pid: number, memory: WebAssembly.Memory): any { + return { + pid, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer), + consecutiveSyscalls: 0, + }; +} + +function controlTaskQueues(): { + setImmediate: ReturnType; + queueMicrotask: ReturnType; + runNextImmediate(): void; +} { + const immediateCallbacks: Array<() => void> = []; + const setImmediate = vi.spyOn(globalThis, "setImmediate").mockImplementation( + ((callback: (...args: any[]) => void, ...args: any[]) => { + immediateCallbacks.push(() => callback(...args)); + return 0 as any; + }) as typeof globalThis.setImmediate, + ); + const queueMicrotask = vi.spyOn(globalThis, "queueMicrotask").mockImplementation( + (callback: VoidFunction) => { + throw new Error(`unexpected queueMicrotask callback: ${String(callback)}`); + }, + ); + + return { + setImmediate, + queueMicrotask, + runNextImmediate(): void { + const callback = immediateCallbacks.shift(); + expect(callback, "expected a queued setImmediate callback").toBeDefined(); + callback!(); + }, + }; +} diff --git a/host/test/chown-sentinel.test.ts b/host/test/chown-sentinel.test.ts new file mode 100644 index 0000000000..8cd69527db --- /dev/null +++ b/host/test/chown-sentinel.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +describe("chown and lchown ownership semantics", () => { + it.each([".wasm", ".wasm64.wasm"])( + "preserves sentinels and changes final links without following (%s)", + async (suffix) => { + const program = join(__dirname, `../../examples/chown_sentinel_test${suffix}`); + const result = await runCentralizedProgram({ + programPath: program, + argv: ["chown-sentinel-test"], + timeout: 15_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("CHOWN_SENTINEL_PASS"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/host/test/clock-id-validation.test.ts b/host/test/clock-id-validation.test.ts new file mode 100644 index 0000000000..a7df2cdecf --- /dev/null +++ b/host/test/clock-id-validation.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/clock_getcpuclockid_test.wasm"); + +describe.skipIf(!existsSync(program))("encoded process CPU clock IDs", () => { + it("maps an invalid negative pid to ESRCH and rejects positive clock ID 10", async () => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["clock_getcpuclockid_test"], + useDefaultRootfs: false, + timeout: 10_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PASS clock id validation"); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/connect-pending-retry.test.ts b/host/test/connect-pending-retry.test.ts new file mode 100644 index 0000000000..1fa3c7a385 --- /dev/null +++ b/host/test/connect-pending-retry.test.ts @@ -0,0 +1,180 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ARG_SIZE, + CH_ARGS, + CH_ERRNO, + CH_RETURN, + CH_SYSCALL, +} from "../src/generated/abi"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const EINPROGRESS = 115; +const EALREADY = 114; +const ECONNREFUSED = 111; + +type KernelResult = { retVal: number; errVal: number }; + +function createSharedMemory(pages = 2): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); +} + +function createConnectHarness( + results: KernelResult[], + options: { nonblock?: boolean; family?: number } = {}, +) { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel: any = { + pid: 42, + channelOffset: 0, + memory: processMemory, + i32View: new Int32Array(processMemory.buffer), + }; + const fd = 7; + const addrPtr = 1024; + const addrLen = 16; + const args = [fd, addrPtr, addrLen, 0, 0, 0]; + const processView = new DataView(processMemory.buffer); + processView.setUint32(CH_SYSCALL, ABI_SYSCALLS.Connect, true); + args.forEach((arg, index) => { + processView.setBigInt64(CH_ARGS + index * CH_ARG_SIZE, BigInt(arg), true); + }); + processView.setUint16(addrPtr, options.family ?? 2, true); + processView.setUint16(addrPtr + 2, 80, false); + new Uint8Array(processMemory.buffer, addrPtr + 4, 4).set([203, 0, 113, 9]); + + let resultIndex = 0; + const handleChannel = vi.fn(() => { + const result = results[Math.min(resultIndex, results.length - 1)]; + resultIndex++; + const kernelView = new DataView(kernelMemory.buffer); + kernelView.setBigInt64(CH_RETURN, BigInt(result.retVal), true); + kernelView.setUint32(CH_ERRNO, result.errVal, true); + return 0; + }); + const completeChannel = 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_is_fd_nonblock: vi.fn(() => options.nonblock ? 1 : 0), + }, + }, + kernelMemory, + scratchOffset: 0, + currentHandlePid: 0, + config: {}, + syscallRing: new Map(), + channelTids: new Map(), + syscallTraceEnabled: false, + sharedMmapBackings: new Map(), + hostReaped: new Set(), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingSleeps: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + socketTimeoutTimers: new Map(), + isRegisteredChannel: vi.fn(() => true), + isAsyncChannelProcessActive: vi.fn(() => true), + deferChannelWhileStopped: vi.fn(() => false), + synchronizeSharedMemoryForBoundary: vi.fn(), + bindKernelTidForChannel: vi.fn(), + highControlFloorForProcess: vi.fn(() => null), + getProcessExitSignal: vi.fn(() => 0), + dequeueSignalForDelivery: vi.fn(() => 0), + finishSignalTermination: vi.fn(() => false), + completeChannel, + completeChannelRaw: vi.fn(), + relistenChannel: vi.fn(), + }); + + return { args, channel, completeChannel, handleChannel, worker }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("pending AF_INET connect routing", () => { + it("preserves EINPROGRESS and EALREADY for a non-blocking socket", () => { + const harness = createConnectHarness( + [ + { retVal: -1, errVal: EINPROGRESS }, + { retVal: -1, errVal: EALREADY }, + ], + { nonblock: true }, + ); + + harness.worker.handleSyscall(harness.channel); + harness.worker.handleSyscall(harness.channel); + + expect(harness.completeChannel).toHaveBeenCalledTimes(2); + expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([-1, EINPROGRESS]); + expect(harness.completeChannel.mock.calls[1].slice(-2)).toEqual([-1, EALREADY]); + expect(harness.worker.pendingPollRetries.size).toBe(0); + }); + + it("retries a blocking connect until success", () => { + vi.useFakeTimers(); + const harness = createConnectHarness([ + { retVal: -1, errVal: EINPROGRESS }, + { retVal: 0, errVal: 0 }, + ]); + + harness.worker.handleSyscall(harness.channel); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe(true); + + vi.advanceTimersByTime(10); + + expect(harness.handleChannel).toHaveBeenCalledTimes(2); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([0, 0]); + expect(harness.worker.pendingPollRetries.size).toBe(0); + }); + + it("keeps a blocking EALREADY retry parked and then returns the failure", () => { + vi.useFakeTimers(); + const harness = createConnectHarness([ + { retVal: -1, errVal: EINPROGRESS }, + { retVal: -1, errVal: EALREADY }, + { retVal: -1, errVal: ECONNREFUSED }, + ]); + + harness.worker.handleSyscall(harness.channel); + vi.advanceTimersByTime(10); + expect(harness.handleChannel).toHaveBeenCalledTimes(2); + expect(harness.completeChannel).not.toHaveBeenCalled(); + expect(harness.worker.pendingPollRetries.has(harness.channel)).toBe(true); + + vi.advanceTimersByTime(10); + + expect(harness.handleChannel).toHaveBeenCalledTimes(3); + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([-1, ECONNREFUSED]); + expect(harness.worker.pendingPollRetries.size).toBe(0); + }); + + it("does not apply the host-delegated AF_INET retry rule to AF_UNIX", () => { + const harness = createConnectHarness( + [{ retVal: -1, errVal: EINPROGRESS }], + { family: 1 }, + ); + + harness.worker.handleSyscall(harness.channel); + + expect(harness.completeChannel).toHaveBeenCalledOnce(); + expect(harness.completeChannel.mock.calls[0].slice(-2)).toEqual([-1, EINPROGRESS]); + expect(harness.worker.pendingPollRetries.size).toBe(0); + }); + + it("names EALREADY in syscall diagnostics", () => { + const worker: any = Object.create(CentralizedKernelWorker.prototype); + + expect(worker.formatSyscallReturn(ABI_SYSCALLS.Connect, -1, EALREADY)) + .toBe(" = -1 (EALREADY)"); + }); +}); diff --git a/host/test/datagram-wakeup.test.ts b/host/test/datagram-wakeup.test.ts new file mode 100644 index 0000000000..35293d7c05 --- /dev/null +++ b/host/test/datagram-wakeup.test.ts @@ -0,0 +1,77 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const WAKE_DATAGRAM_WRITABLE = 8; + +function createSharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function createWorkerHarness(): any { + const memory = createSharedMemory(); + const scratchOffset = 128; + const drain = (outPtr: number): number => { + const bytes = new Uint8Array(memory.buffer, outPtr, 5); + bytes.fill(0); + bytes[4] = WAKE_DATAGRAM_WRITABLE; + return 1; + }; + + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelInstance: { exports: { kernel_drain_wakeup_events: drain } }, + kernelMemory: memory, + scratchOffset, + processes: new Map(), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + wakeScheduled: false, + }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("datagram send-state wakeups", () => { + it("retries blocked writes immediately without bypassing signal-safe poll deferral", () => { + vi.useFakeTimers(); + const worker = createWorkerHarness(); + const channel = { pid: 42, channelOffset: 0, memory: createSharedMemory() }; + const pollChannel = { pid: channel.pid, channelOffset: 64, memory: channel.memory }; + worker.processes.set(channel.pid, { channels: [channel, pollChannel] }); + + const fallback = vi.fn(); + const timer = setTimeout(fallback, 1); + worker.pendingPollRetries.set(channel, { + timer, + channel, + pipeIndices: [], + deadline: Date.now() + 1, + isWriteRetry: true, + }); + worker.pendingPollRetries.set(pollChannel, { + timer: null, + channel: pollChannel, + pipeIndices: [], + needsSignalSafeWake: true, + }); + worker.retrySyscall = vi.fn(); + worker.scheduleWakeBlockedRetries = vi.fn(); + worker.scheduleWakeBlockedRetriesDeferred = vi.fn(); + + worker.drainAndProcessWakeupEvents(); + + expect(worker.retrySyscall).toHaveBeenCalledOnce(); + expect(worker.retrySyscall).toHaveBeenCalledWith(channel); + expect(worker.pendingPollRetries.has(channel)).toBe(false); + expect(worker.pendingPollRetries.has(pollChannel)).toBe(true); + expect(worker.scheduleWakeBlockedRetries).not.toHaveBeenCalled(); + expect(worker.scheduleWakeBlockedRetriesDeferred).toHaveBeenCalledOnce(); + + vi.runAllTimers(); + expect(fallback).not.toHaveBeenCalled(); + }); +}); diff --git a/host/test/deferred-worker-start.test.ts b/host/test/deferred-worker-start.test.ts new file mode 100644 index 0000000000..1b0d3bbf91 --- /dev/null +++ b/host/test/deferred-worker-start.test.ts @@ -0,0 +1,363 @@ +import { describe, expect, it, vi } from "vitest"; +import { DeferredWorkerHandle } from "../src/deferred-worker-handle"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; +import { MockWorkerAdapter } from "../src/worker-adapter"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_SYSCALL, + PROCESS_STATE_EXITED, + PROCESS_STATE_RUNNING, + PROCESS_STATE_STOPPED, +} from "../src/generated/abi"; + +describe("DeferredWorkerHandle", () => { + it("does not construct or dispatch to a Worker before start", () => { + const adapter = new MockWorkerAdapter(); + const handle = new DeferredWorkerHandle(() => + adapter.createWorker({ pid: 41 }), + ); + const messages: unknown[] = []; + handle.on("message", (message) => messages.push(message)); + handle.postMessage({ type: "queued" }); + + expect(adapter.allWorkers).toHaveLength(0); + expect(handle.start()).toBe(true); + expect(adapter.allWorkers).toHaveLength(1); + expect(adapter.lastWorkerData).toEqual({ pid: 41 }); + expect(adapter.lastWorker!.sentMessages).toEqual([{ type: "queued" }]); + + adapter.lastWorker!.simulateMessage({ type: "ready" }); + expect(messages).toEqual([{ type: "ready" }]); + }); + + it("permanently cancels construction when terminated before start", async () => { + const create = vi.fn(() => new MockWorkerAdapter().createWorker({})); + const handle = new DeferredWorkerHandle(create); + + await handle.terminate(); + + expect(handle.start()).toBe(false); + expect(create).not.toHaveBeenCalled(); + }); + + it("rethrows synchronous construction failure to the lifecycle rollback path", () => { + const failure = new Error("Worker constructor failed"); + const create = vi.fn(() => { + throw failure; + }); + const onError = vi.fn(); + const handle = new DeferredWorkerHandle(create); + handle.on("error", onError); + handle.postMessage({ type: "queued" }); + + expect(() => handle.start()).toThrow(failure); + expect(onError).not.toHaveBeenCalled(); + expect(handle.start()).toBe(false); + expect(create).toHaveBeenCalledOnce(); + }); +}); + +describe("stopped process Worker launch gate", () => { + it("holds construction through STOPPED and releases it on SIGCONT", () => { + let processState = 1; + const memory = createSharedMemory(); + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(memory, () => processState); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("deferred"); + expect(start).not.toHaveBeenCalled(); + + processState = 0; + worker.resumeStoppedProcess(41); + + expect(start).toHaveBeenCalledOnce(); + expect(cancel).not.toHaveBeenCalled(); + expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); + }); + + it("cancels an exact deferred generation on exec replacement", () => { + let processState = 1; + const oldMemory = createSharedMemory(); + const newMemory = createSharedMemory(); + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(oldMemory, () => processState); + + expect( + worker.startProcessWorkerWhenRunnable(41, oldMemory, start, cancel), + ).toBe("deferred"); + worker.processes.set(41, { + memory: newMemory, + channels: [ + { + pid: 41, + memory: newMemory, + channelOffset: 0, + i32View: new Int32Array(newMemory.buffer), + consecutiveSyscalls: 0, + }, + ], + }); + processState = 0; + worker.resumeStoppedProcess(41); + + expect(start).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + }); + + it("ignores a stale continue wake while the current Process is stopped", () => { + const memory = createSharedMemory(); + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(memory, () => 1); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("deferred"); + worker.resumeStoppedProcess(41); + + expect(start).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(worker.deferredProcessWorkerStarts.has(41)).toBe(true); + }); + + it("never queues a launch for an exited child", () => { + const memory = createSharedMemory(); + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(memory, () => 2); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("dead"); + expect(start).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); + }); + + it("preflights a continuation observed before async child registration", () => { + let processState = PROCESS_STATE_STOPPED; + const memory = createSharedMemory(); + const channel = { + pid: 41, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer), + consecutiveSyscalls: 0, + }; + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(memory, () => processState); + // Exec handoff retains the pid registration but temporarily has no exact + // channel; an async fork/spawn can also have no registration at all. + worker.processes.set(41, { memory, channels: [] }); + + processState = PROCESS_STATE_RUNNING; + expect(worker.resumeStoppedProcess(41)).toBe(true); + expect(worker.pendingResumePids.has(41)).toBe(true); + + worker.processes.set(41, { memory, channels: [channel] }); + worker.kernel = { toKernelPtr: (value: number) => value }; + worker.kernelMemory = createSharedMemory(); + worker.scratchOffset = 0; + worker.channelTids = new Map(); + worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { + processState = PROCESS_STATE_EXITED; + return 0; + }); + worker.finishSignalTermination = vi.fn(() => { + if (processState !== PROCESS_STATE_EXITED) return false; + worker.discardStoppedChannelStateForProcess(41); + return true; + }); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("dead"); + expect(start).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + expect(worker.pendingResumePids.has(41)).toBe(false); + }); + + it("drains a re-stop generated by direct late-registration preflight", () => { + let processState = PROCESS_STATE_RUNNING; + const memory = createSharedMemory(); + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(memory, () => processState); + worker.pendingResumePids.add(41); + worker.stoppedPids.add(41); + worker.kernel = { toKernelPtr: (value: number) => value }; + worker.kernelMemory = createSharedMemory(); + worker.scratchOffset = 0; + worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { + processState = PROCESS_STATE_STOPPED; + return 0; + }); + worker.finishSignalTermination = vi.fn(() => false); + worker.drainAndProcessWakeupEvents = vi.fn(); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("deferred"); + + expect(start).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + expect(worker.drainAndProcessWakeupEvents).toHaveBeenCalledOnce(); + expect(worker.stoppedPids.has(41)).toBe(true); + }); + + it("cancels pending launches during process teardown", () => { + const memory = createSharedMemory(); + const start = vi.fn(); + const cancel = vi.fn(); + const worker = createWorkerHarness(memory, () => 1); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("deferred"); + worker.discardStoppedChannelStateForProcess(41); + + expect(start).not.toHaveBeenCalled(); + expect(cancel).toHaveBeenCalledOnce(); + expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); + }); + + it("turns deferred constructor failure into process exit and full teardown", () => { + let processState = 1; + const memory = createSharedMemory(); + const failure = new Error("Worker constructor failed after SIGCONT"); + const start = vi.fn(() => { + throw failure; + }); + const cancel = vi.fn(); + const laterStart = vi.fn(); + const laterCancel = vi.fn(); + const notifyCrash = vi.fn(); + const onExit = vi.fn(); + const worker = createWorkerHarness(memory, () => processState); + worker.notifyHostProcessCrashed = notifyCrash; + worker.callbacks = { onExit }; + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel), + ).toBe("deferred"); + expect( + worker.startProcessWorkerWhenRunnable( + 41, + memory, + laterStart, + laterCancel, + ), + ).toBe("deferred"); + + processState = 0; + worker.resumeStoppedProcess(41); + + expect(start).toHaveBeenCalledOnce(); + expect(cancel).toHaveBeenCalledOnce(); + expect(laterStart).not.toHaveBeenCalled(); + expect(laterCancel).toHaveBeenCalledOnce(); + expect(notifyCrash).toHaveBeenCalledWith(41); + expect(onExit).toHaveBeenCalledWith(41, 139); + expect(worker.deferredProcessWorkerStarts.has(41)).toBe(false); + }); + + it("rolls back only a deferred clone when its thread Worker cannot start", () => { + let processState = 1; + const memory = createSharedMemory(); + const channel = { + pid: 41, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer), + consecutiveSyscalls: 0, + }; + const ptidPtr = 512; + const tid = 99; + const view = new DataView(memory.buffer); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Clone, true); + view.setBigInt64(CH_ARGS, BigInt(0x00100000), true); + view.setBigInt64(CH_ARGS + 2 * CH_ARG_SIZE, BigInt(ptidPtr), true); + view.setInt32(ptidPtr, tid, true); + + const start = vi.fn(() => { + throw new Error("thread Worker failed"); + }); + const cancel = vi.fn(); + const notifyCrash = vi.fn(); + const publish = vi.fn(); + const worker = createWorkerHarness(memory, () => processState); + worker.processes.set(41, { memory, channels: [channel] }); + worker.notifyHostProcessCrashed = notifyCrash; + worker.publishPreparedChannelCompletion = publish; + worker.parkedChannelCompletions.set(channel, { + prepared: { + kind: "marshalled", + outputWrites: [], + retVal: tid, + errVal: 0, + relistenRequested: true, + }, + relistenRequested: true, + }); + + expect( + worker.startProcessWorkerWhenRunnable(41, memory, start, cancel, () => + worker.failDeferredCloneLaunch(41, tid, 12), + ), + ).toBe("deferred"); + + processState = 0; + worker.resumeStoppedProcess(41); + + expect(cancel).toHaveBeenCalledOnce(); + expect(notifyCrash).not.toHaveBeenCalled(); + expect(view.getInt32(ptidPtr, true)).toBe(0); + expect(publish).toHaveBeenCalledWith( + channel, + expect.objectContaining({ retVal: -1, errVal: 12 }), + ); + }); +}); + +function createWorkerHarness( + memory: WebAssembly.Memory, + getProcessState: () => number, +): any { + const channel = { + pid: 41, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer), + consecutiveSyscalls: 0, + }; + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + kernelInstance: { + exports: { + kernel_get_process_state: getProcessState, + kernel_get_process_exit_signal: vi.fn(() => -1), + }, + }, + processes: new Map([[41, { memory, channels: [channel] }]]), + channelTids: new Map(), + stoppedPids: new Set(), + pendingResumePids: new Set(), + deferredProcessWorkerStarts: new Map(), + parkedChannelCompletions: new Map(), + deferredStoppedChannels: new Map(), + }); +} + +function createSharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); +} diff --git a/host/test/dinit-image-helpers.test.ts b/host/test/dinit-image-helpers.test.ts new file mode 100644 index 0000000000..4f92296d2c --- /dev/null +++ b/host/test/dinit-image-helpers.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from "vitest"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { findRepoRoot } from "../src/binary-resolver"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { addDinitBaseSystemFiles } from "../../images/vfs/scripts/dinit-image-helpers"; + +const O_RDONLY = 0; + +function readGuestFile(fs: MemoryFileSystem, path: string): string { + const size = fs.stat(path).size; + const fd = fs.open(path, O_RDONLY, 0); + try { + const bytes = new Uint8Array(size); + const count = fs.read(fd, bytes, null, bytes.byteLength); + return new TextDecoder().decode(bytes.subarray(0, count)); + } finally { + fs.close(fd); + } +} + +describe("dinit-derived image system databases", () => { + it("copies the authoritative rootfs services database without reducing aliases", () => { + const fs = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + addDinitBaseSystemFiles(fs); + + const source = readFileSync( + join(findRepoRoot(), "images", "rootfs", "etc", "services"), + "utf8", + ); + const derived = readGuestFile(fs, "/etc/services"); + + expect(derived).toBe(source); + expect(derived).toContain("www www-http"); + expect(derived).toContain("postgresql\t5432/tcp"); + }); +}); diff --git a/host/test/dylink.test.ts b/host/test/dylink.test.ts index c9ad7bc05f..49ae415791 100644 --- a/host/test/dylink.test.ts +++ b/host/test/dylink.test.ts @@ -3,11 +3,28 @@ */ import { describe, it, expect } from "vitest"; -import { parseDylinkSection, loadSharedLibrary, loadSharedLibrarySync, DynamicLinker, type LoadSharedLibraryOptions } from "../src/dylink.ts"; +import { + createCppExceptionTag, + createLongjmpTag, + parseDylinkSection, + loadSharedLibrary, + loadSharedLibrarySync, + DynamicLinker, + FORK_CAP_DYLINK_MAIN, + FORK_CAP_SIDE_ENTRY, + FORK_CAPABILITIES_SECTION, + FORK_CAPABILITIES_VERSION, + forkInstrumentRoleAvailable, + readForkInstrumentCapabilityClaim, + readForkInstrumentCapabilities, + type LoadSharedLibraryOptions, + type SideModuleForkState, +} from "../src/dylink.ts"; import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; +import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; function hasCompiler(): boolean { try { @@ -31,6 +48,234 @@ function buildSharedLib(source: string, name: string): Uint8Array { return new Uint8Array(readFileSync(soPath)); } +/** Compile a tiny side module and prepend the required first dylink.0 section. */ +function appendCustomSection(module: Uint8Array, name: string, data: Uint8Array): Uint8Array { + const nameBytes = new TextEncoder().encode(name); + const payloadSize = 1 + nameBytes.length + data.length; + if (nameBytes.length >= 128 || payloadSize >= 128) { + throw new Error("test custom section helper only supports one-byte LEB lengths"); + } + const section = new Uint8Array(2 + payloadSize); + section[0] = 0; + section[1] = payloadSize; + section[2] = nameBytes.length; + section.set(nameBytes, 3); + section.set(data, 3 + nameBytes.length); + const out = new Uint8Array(module.length + section.length); + out.set(module); + out.set(section, module.length); + return out; +} + +function buildDylinkWat( + wat: string, + name: string, + forkCapabilities?: number, + tableSize = 0, + memorySize = 0, + wat2wasmFlags: string[] = [], + tlsExports: string[] = [], +): Uint8Array { + const dir = join(tmpdir(), "wasm-dylink-wat-test"); + mkdirSync(dir, { recursive: true }); + const watPath = join(dir, `${name}.wat`); + const wasmPath = join(dir, `${name}.wasm`); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", ["--enable-threads", ...wat2wasmFlags, watPath, "-o", wasmPath], { + stdio: "pipe", + }); + const module = new Uint8Array(readFileSync(wasmPath)); + const dylinkName = new TextEncoder().encode("dylink.0"); + // Memory-info subsection: size, align=0, table size, table align=0. + // Optional export-info entries pin TLS-relative relocation behavior. + const exportInfoBody = tlsExports.flatMap((exportName) => { + const bytes = [...new TextEncoder().encode(exportName)]; + if (bytes.length >= 128) throw new Error("test TLS export name is too long"); + return [bytes.length, ...bytes, 1]; // WASM_DYLINK_FLAG_TLS + }); + const exportInfo = tlsExports.length > 0 + ? [3, 1 + exportInfoBody.length, tlsExports.length, ...exportInfoBody] + : []; + const payload = new Uint8Array(1 + dylinkName.length + 6 + exportInfo.length); + payload[0] = dylinkName.length; + payload.set(dylinkName, 1); + payload.set([1, 4, memorySize, 0, tableSize, 0], 1 + dylinkName.length); + payload.set(exportInfo, 1 + dylinkName.length + 6); + const section = new Uint8Array(2 + payload.length); + section[0] = 0; + section[1] = payload.length; + section.set(payload, 2); + const out = new Uint8Array(module.length + section.length); + out.set(module.subarray(0, 8), 0); + out.set(section, 8); + out.set(module.subarray(8), 8 + section.length); + return forkCapabilities === undefined + ? out + : appendCustomSection( + out, + FORK_CAPABILITIES_SECTION, + new Uint8Array([FORK_CAPABILITIES_VERSION, forkCapabilities]), + ); +} + +describe.skipIf(typeof WebAssembly.Tag !== "function")("longjmp tag identity", () => { + const cases = [ + { ptrWidth: 4 as const, wasmType: "i32", value: 37 }, + { ptrWidth: 8 as const, wasmType: "i64", value: 37n }, + ]; + + it.each(cases)( + "shares one process-owned $wasmType tag with a side module", + ({ ptrWidth, wasmType, value }) => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (tag $longjmp (import "env" "__c_longjmp") (param ${wasmType})) + (func (export "throw_longjmp") (param $value ${wasmType}) + local.get $value + throw $longjmp)) + `, `longjmp-${wasmType}`, undefined, 0, 0, ["--enable-exceptions"]); + const options = createSideForkLoadOptions(); + const longjmpTag = createLongjmpTag(ptrWidth)!; + options.ptrWidth = ptrWidth; + options.longjmpTag = longjmpTag; + // A same-named main export is not authoritative for this reserved tag. + options.globalSymbols.set("__c_longjmp", () => 0); + + const lib = loadSharedLibrarySync(`liblongjmp-${wasmType}.so`, wasmBytes, options); + const throwLongjmp = lib.exports.throw_longjmp as (arg: number | bigint) => void; + let caught: unknown; + try { + throwLongjmp(value); + } catch (error) { + caught = error; + } + + const WasmException = (WebAssembly as typeof WebAssembly & { + Exception: new (...args: unknown[]) => Error; + }).Exception; + expect(caught).toBeInstanceOf(WasmException); + const exception = caught as Error & { + is: (tag: WebAssembly.Tag) => boolean; + getArg: (tag: WebAssembly.Tag, index: number) => unknown; + }; + expect(exception.is(longjmpTag)).toBe(true); + expect(exception.getArg(longjmpTag, 0)).toBe(value); + }, + ); + + it.each(cases)( + "creates one pointer-width-aware $wasmType fallback for standalone linkers", + ({ ptrWidth, wasmType, value }) => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (tag $longjmp (import "env" "__c_longjmp") (param ${wasmType})) + (func (export "throw_longjmp") (param $value ${wasmType}) + local.get $value + throw $longjmp)) + `, `fallback-longjmp-${wasmType}`, undefined, 0, 0, ["--enable-exceptions"]); + const options = createSideForkLoadOptions(); + options.ptrWidth = ptrWidth; + + const first = loadSharedLibrarySync(`libfallback-${wasmType}-one.so`, wasmBytes, options); + const fallbackTag = options.longjmpTag!; + expect(fallbackTag).toBeInstanceOf(WebAssembly.Tag); + const second = loadSharedLibrarySync(`libfallback-${wasmType}-two.so`, wasmBytes, options); + expect(options.longjmpTag).toBe(fallbackTag); + + for (const lib of [first, second]) { + let caught: unknown; + try { + (lib.exports.throw_longjmp as (arg: number | bigint) => void)(value); + } catch (error) { + caught = error; + } + const exception = caught as { + is: (tag: WebAssembly.Tag) => boolean; + getArg: (tag: WebAssembly.Tag, index: number) => unknown; + }; + expect(exception.is(fallbackTag)).toBe(true); + expect(exception.getArg(fallbackTag, 0)).toBe(value); + } + }, + ); + + it("rejects a lookalike tag before side-module instantiation", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (tag $longjmp (import "env" "__c_longjmp") (param i32))) + `, "invalid-longjmp-tag", undefined, 0, 0, ["--enable-exceptions"]); + const options = createSideForkLoadOptions(); + options.longjmpTag = {} as WebAssembly.Tag; + + expect(() => loadSharedLibrarySync("libinvalid-longjmp.so", wasmBytes, options)) + .toThrow(/__c_longjmp must be an actual WebAssembly\.Tag/); + }); +}); + +describe.skipIf(typeof WebAssembly.Tag !== "function")("C++ exception tag identity", () => { + const cases = [ + { ptrWidth: 4 as const, wasmType: "i32", value: 42 }, + { ptrWidth: 8 as const, wasmType: "i64", value: 42n }, + ]; + + it.each(cases)( + "shares one process-owned $wasmType tag across throwing and catching side modules", + ({ ptrWidth, wasmType, value }) => { + const throwerBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (tag $cpp (import "env" "__cpp_exception") (param ${wasmType})) + (func (export "throw_cpp") (param $value ${wasmType}) + local.get $value + throw $cpp)) + `, `cpp-thrower-${wasmType}`, undefined, 0, 0, ["--enable-exceptions"]); + const catcherBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "throw_cpp" (func $throw_cpp (param ${wasmType}))) + (tag $cpp (import "env" "__cpp_exception") (param ${wasmType})) + (func (export "catch_cpp") (param $value ${wasmType}) (result ${wasmType}) + (try (result ${wasmType}) + (do + local.get $value + call $throw_cpp + unreachable) + (catch $cpp)))) + `, `cpp-catcher-${wasmType}`, undefined, 0, 0, ["--enable-exceptions"]); + const options = createSideForkLoadOptions(); + options.ptrWidth = ptrWidth; + options.cppExceptionTag = createCppExceptionTag(ptrWidth)!; + + loadSharedLibrarySync(`libcpp-thrower-${wasmType}.so`, throwerBytes, options); + const processTag = options.cppExceptionTag; + const catcher = loadSharedLibrarySync( + `libcpp-catcher-${wasmType}.so`, + catcherBytes, + options, + ); + + expect(options.cppExceptionTag).toBe(processTag); + expect((catcher.exports.catch_cpp as Function)(value)).toBe(value); + }, + ); + + it("rejects a lookalike process C++ tag before instantiation", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (tag $cpp (import "env" "__cpp_exception") (param i32))) + `, "invalid-cpp-tag", undefined, 0, 0, ["--enable-exceptions"]); + const options = createSideForkLoadOptions(); + options.cppExceptionTag = {} as WebAssembly.Tag; + + expect(() => loadSharedLibrarySync("libinvalid-cpp-tag.so", wasmBytes, options)) + .toThrow(/__cpp_exception must be an actual WebAssembly\.Tag/); + }); +}); + describe.skipIf(!hasCompiler())("dylink.0 parser", () => { it("parses a simple shared library", () => { const wasmBytes = buildSharedLib( @@ -259,6 +504,552 @@ describe.skipIf(!hasCompiler())("synchronous loading (loadSharedLibrarySync)", ( const square = lib.exports.square as Function; expect(square(7)).toBe(49); }); + +}); + +function createSideForkLoadOptions(): LoadSharedLibraryOptions { + return { + memory: new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }), + table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), + stackPointer: new WebAssembly.Global({ value: "i32", mutable: true }, 65536), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }; +} + +describe("side-module fork contract", () => { + it("rejects an uninstrumented side module that imports fork", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-uninstrumented"); + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + + expect(() => loadSharedLibrarySync("libbadfork.so", wasmBytes, options)) + .toThrow(/requires complete side-module instrumentation/); + }); + + it("applies the generated ABI transition to a legacy five-export side artifact", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-generic"); + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + + const load = () => loadSharedLibrarySync("liblegacyfork.so", wasmBytes, options); + const legacyAllowed = forkInstrumentRoleAvailable( + { present: false, flags: 0 }, + FORK_CAP_SIDE_ENTRY, + ); + if (legacyAllowed) { + expect(load).not.toThrow(); + } else { + expect(load).toThrow(/versioned side-entry capability/); + } + }); + + it("makes missing side and main role claims mandatory at ABI 17", () => { + const wasmBytes = buildDylinkWat(` + (module (import "env" "memory" (memory 1 100 shared))) + `, "legacy-capability-absence"); + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + const claim = readForkInstrumentCapabilityClaim(module); + + expect(claim).toEqual({ present: false, flags: 0 }); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_SIDE_ENTRY, 16)).toBe(true); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_DYLINK_MAIN, 16)).toBe(true); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_SIDE_ENTRY, 17)).toBe(false); + expect(forkInstrumentRoleAvailable(claim, FORK_CAP_DYLINK_MAIN, 17)).toBe(false); + expect(forkInstrumentRoleAvailable( + { present: true, flags: FORK_CAP_SIDE_ENTRY }, + FORK_CAP_DYLINK_MAIN, + 16, + )).toBe(false); + }); + + it("rejects a marker-present artifact that does not claim side-entry coverage", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0) + (func (export "side_fork") (result i32) call $fork)) + `, "side-fork-wrong-marker", 0); + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + + expect(() => loadSharedLibrarySync("libwrongmarker.so", wasmBytes, options)) + .toThrow(/versioned side-entry capability/); + }); + + it("reads the versioned side-entry capability independently", () => { + const wasmBytes = buildDylinkWat(` + (module (import "env" "memory" (memory 1 100 shared))) + `, "side-capability-marker", FORK_CAP_SIDE_ENTRY); + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + expect(readForkInstrumentCapabilityClaim(module)).toEqual({ + present: true, + flags: FORK_CAP_SIDE_ENTRY, + }); + expect(readForkInstrumentCapabilities(module)).toBe(FORK_CAP_SIDE_ENTRY); + }); + + it("rejects a malformed marker even during the ABI-16 compatibility window", () => { + const base = buildDylinkWat(` + (module (import "env" "memory" (memory 1 100 shared))) + `, "malformed-capability-marker"); + const wasmBytes = appendCustomSection( + base, + FORK_CAPABILITIES_SECTION, + new Uint8Array([FORK_CAPABILITIES_VERSION]), + ); + const module = new WebAssembly.Module(wasmBytes as unknown as BufferSource); + + expect(() => readForkInstrumentCapabilityClaim(module)) + .toThrow(/malformed kandelo\.wpk_fork\.capabilities custom section/); + }); + + it("reports an explicit stale-main diagnostic for a valid side artifact", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (func (export "wpk_fork_unwind_begin") (param i32)) + (func (export "wpk_fork_unwind_end")) + (func (export "wpk_fork_rewind_begin") (param i32)) + (func (export "wpk_fork_rewind_end")) + (func (export "wpk_fork_state") (result i32) i32.const 0) + (func (export "side_fork") (result i32) call $fork)) + `, "side-with-stale-main", FORK_CAP_SIDE_ENTRY); + const options = createSideForkLoadOptions(); + options.sideModuleForkUnavailableReason = + "main module lacks the versioned dlopen-main fork capability; rebuild it"; + + expect(() => loadSharedLibrarySync("libside.so", wasmBytes, options)) + .toThrow(/main module lacks the versioned dlopen-main fork capability; rebuild it/); + }); + + it("drives repeated instrumented side-module forks through exact states", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (global $state (mut i32) (i32.const 0)) + (global $buf (mut i32) (i32.const 0)) + (func (export "wpk_fork_unwind_begin") (param $addr i32) + local.get $addr + global.set $buf + i32.const 1 + global.set $state) + (func (export "wpk_fork_unwind_end") + i32.const 0 + global.set $state) + (func (export "wpk_fork_rewind_begin") (param $addr i32) + local.get $addr + global.set $buf + i32.const 2 + global.set $state) + (func (export "wpk_fork_rewind_end") + i32.const 0 + global.set $state) + (func (export "wpk_fork_state") (result i32) + global.get $state) + (func (export "side_fork_with_local") (result i32) + i32.const 41 + call $fork + i32.add)) + `, "side-fork-instrumented", FORK_CAP_SIDE_ENTRY); + const options = createSideForkLoadOptions(); + let forkResult = 0; + let active: SideModuleForkState | null = null; + options.sideModuleFork = { + setActiveFork: (state) => { + expect(active).toBeNull(); + active = state; + }, + clearActiveFork: (state) => { + expect(active).toBe(state); + active = null; + }, + invokeMainFork: () => forkResult, + }; + + const lib = loadSharedLibrarySync("libsidefork.so", wasmBytes, options); + const sideFork = lib.exports.side_fork_with_local as () => number; + const state = lib.instance.exports.wpk_fork_state as () => number; + const unwindEnd = lib.instance.exports.wpk_fork_unwind_end as () => void; + const rewindBegin = lib.instance.exports.wpk_fork_rewind_begin as (addr: number) => void; + + for (const expectedForkResult of [101, 202]) { + forkResult = 0; + expect(sideFork()).toBe(41); + expect(state()).toBe(1); + expect(active?.forkBufAddr).toBe(lib.forkBufAddr); + expect(active?.forkBufSize).toBe(FORK_SAVE_BUFFER_SIZE); + + unwindEnd(); + forkResult = expectedForkResult; + rewindBegin(lib.forkBufAddr!); + expect(sideFork()).toBe(41 + expectedForkResult); + expect(state()).toBe(0); + expect(active).toBeNull(); + } + }); + + it("allows independent extensions but rejects visible side-to-side fork nesting", () => { + const options = createSideForkLoadOptions(); + options.sideModuleFork = { + setActiveFork: () => {}, + clearActiveFork: () => {}, + invokeMainFork: () => 0, + }; + const provider = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "provider_value") (result i32) i32.const 7)) + `, "fork-provider"); + loadSharedLibrarySync("libprovider.so", provider, options); + + const independentFork = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "fork" (func $fork (result i32))) + (global $state (mut i32) (i32.const 0)) + (func (export "wpk_fork_unwind_begin") (param i32) + i32.const 1 global.set $state) + (func (export "wpk_fork_unwind_end") i32.const 0 global.set $state) + (func (export "wpk_fork_rewind_begin") (param i32) + i32.const 2 global.set $state) + (func (export "wpk_fork_rewind_end") i32.const 0 global.set $state) + (func (export "wpk_fork_state") (result i32) global.get $state) + (func (export "side_fork") (result i32) call $fork)) + `, "independent-fork-side", FORK_CAP_SIDE_ENTRY); + loadSharedLibrarySync("libindependent-fork.so", independentFork, options); + + const visibleConsumer = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "side_fork" (func $side_fork (result i32))) + (func (export "nested") (result i32) call $side_fork)) + `, "visible-side-consumer"); + expect(() => loadSharedLibrarySync("libnested.so", visibleConsumer, options)) + .toThrow(/fork-capable side-module nesting/); + }); +}); + +describe("dylink symbol interposition", () => { + it("preserves first-definition GOT bindings for duplicate function and data exports", () => { + const firstBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "duplicate_function") (result i32) i32.const 1) + (global (export "duplicate_data") i32 (i32.const 12))) + `, "first-duplicate-exports", undefined, 0, 16); + const secondBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "duplicate_function") (result i32) i32.const 2) + (global (export "duplicate_data") i32 (i32.const 28))) + `, "second-duplicate-exports", undefined, 0, 16); + const options = createSideForkLoadOptions(); + const functionGot = new WebAssembly.Global({ value: "i32", mutable: true }, 0); + const dataGot = new WebAssembly.Global({ value: "i32", mutable: true }, 0); + options.got.set("duplicate_function", functionGot); + options.got.set("duplicate_data", dataGot); + + const first = loadSharedLibrarySync("libfirst.so", firstBytes, options); + const firstFunctionBinding = options.globalSymbols.get("duplicate_function"); + const firstDataBinding = options.globalSymbols.get("duplicate_data"); + const firstFunctionGot = functionGot.value; + const firstDataGot = dataGot.value; + const tableLengthAfterFirst = options.table.length; + + const second = loadSharedLibrarySync("libsecond.so", secondBytes, options); + + expect(options.globalSymbols.get("duplicate_function")).toBe(firstFunctionBinding); + expect(options.globalSymbols.get("duplicate_data")).toBe(firstDataBinding); + expect(functionGot.value).toBe(firstFunctionGot); + expect(dataGot.value).toBe(firstDataGot); + expect((first.exports.duplicate_function as () => number)()).toBe(1); + expect((second.exports.duplicate_function as () => number)()).toBe(2); + expect((second.exports.duplicate_data as WebAssembly.Global).value) + .not.toBe((first.exports.duplicate_data as WebAssembly.Global).value); + expect(options.table.length).toBe(tableLengthAfterFirst + 1); + expect(options.table.get(options.table.length - 1)) + .toBe(second.exports.duplicate_function); + }); +}); + +describe("dylink replay layout and rollback", () => { + it("restores copied live TLS without re-running side-module TLS initialization", () => { + const tlsSide = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__memory_base" (global $memory_base i32)) + (global $tls_base (export "__tls_base") (mut i32) (i32.const 0)) + (global (export "__tls_size") i32 (i32.const 4)) + (global (export "__tls_align") i32 (i32.const 4)) + (global (export "__wasm_lpad_context") i32 (i32.const 8)) + (func $init_tls (export "__wasm_init_tls") (param $base i32) + local.get $base + global.set $tls_base + local.get $base + i32.const 42 + i32.store) + (func $start + global.get $memory_base + i32.load + i32.eqz + if + global.get $memory_base + i32.const 2 + i32.store + global.get $memory_base + i32.const 8 + i32.add + call $init_tls + end) + (start $start) + (func (export "get_tls") (result i32) + global.get $tls_base + i32.load) + (func (export "set_tls") (param $value i32) + global.get $tls_base + local.get $value + i32.store)) + `, "replay-live-tls", undefined, 0, 16, [], ["__wasm_lpad_context"]); + const parent = createSideForkLoadOptions(); + const loaded = loadSharedLibrarySync("libtls.so", tlsSide, parent); + expect(loaded.tlsBase).toBe(1032); + expect((loaded.exports.__wasm_lpad_context as WebAssembly.Global).value) + .toBe(loaded.tlsBase! + 8); + expect((loaded.exports.__tls_size as WebAssembly.Global).value).toBe(4); + expect((loaded.exports.__tls_align as WebAssembly.Global).value).toBe(4); + expect((loaded.exports.get_tls as Function)()).toBe(42); + (loaded.exports.set_tls as Function)(99); + + const child = createSideForkLoadOptions(); + new Uint8Array(child.memory.buffer).set(new Uint8Array(parent.memory.buffer)); + const replayed = loadSharedLibrarySync("libtls.so", tlsSide, child, { + memoryBase: loaded.memoryBase, + tableBase: loaded.tableBase, + tlsBase: loaded.tlsBase, + }); + + expect(replayed.tlsBase).toBe(loaded.tlsBase); + expect((replayed.exports.__wasm_lpad_context as WebAssembly.Global).value) + .toBe(replayed.tlsBase! + 8); + expect((replayed.exports.get_tls as Function)()).toBe(99); + + const ambiguous = createSideForkLoadOptions(); + new Uint8Array(ambiguous.memory.buffer).set(new Uint8Array(parent.memory.buffer)); + expect(() => loadSharedLibrarySync("libtls.so", tlsSide, ambiguous, { + memoryBase: loaded.memoryBase, + tableBase: loaded.tableBase, + tlsBase: 0, + })).toThrow(/missing a valid side-module TLS base/); + }); + + it("restores an i64 TLS-base global for the 64-bit pointer contract", () => { + const tlsSide = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__memory_base" (global $memory_base i32)) + (global $tls_base (export "__tls_base") (mut i64) (i64.const 0)) + (global (export "__tls_size") i32 (i32.const 4)) + (global (export "__tls_align") i32 (i32.const 4)) + (func $init_tls (param $base i32) + local.get $base + i64.extend_i32_u + global.set $tls_base + local.get $base + i32.const 17 + i32.store) + (func $start + global.get $memory_base + i32.load + i32.eqz + if + global.get $memory_base + i32.const 2 + i32.store + global.get $memory_base + i32.const 8 + i32.add + call $init_tls + end) + (start $start) + (func (export "get_tls") (result i32) + global.get $tls_base + i32.wrap_i64 + i32.load) + (func (export "set_tls") (param $value i32) + global.get $tls_base + i32.wrap_i64 + local.get $value + i32.store)) + `, "replay-live-tls-i64", undefined, 0, 16); + const parent = createSideForkLoadOptions(); + parent.ptrWidth = 8; + const loaded = loadSharedLibrarySync("libtls64.so", tlsSide, parent); + (loaded.exports.set_tls as Function)(71); + + const child = createSideForkLoadOptions(); + child.ptrWidth = 8; + new Uint8Array(child.memory.buffer).set(new Uint8Array(parent.memory.buffer)); + const replayed = loadSharedLibrarySync("libtls64.so", tlsSide, child, { + memoryBase: loaded.memoryBase, + tableBase: loaded.tableBase, + tlsBase: loaded.tlsBase, + }); + + expect(replayed.tlsBase).toBe(loaded.tlsBase); + expect((replayed.exports.__tls_base as WebAssembly.Global).value) + .toBe(BigInt(loaded.tlsBase!)); + expect((replayed.exports.get_tls as Function)()).toBe(71); + }); + + it("rejects incomplete, immutable, and out-of-reservation TLS state", () => { + const missingBase = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (global (export "__tls_size") i32 (i32.const 4)) + (global (export "__tls_align") i32 (i32.const 4))) + `, "tls-missing-base", undefined, 0, 16); + expect(() => loadSharedLibrarySync( + "libtls-missing-base.so", + missingBase, + createSideForkLoadOptions(), + )).toThrow(/must export mutable __tls_base/); + + const immutableBase = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (global (export "__tls_base") i32 (i32.const 1032)) + (global (export "__tls_size") i32 (i32.const 4)) + (global (export "__tls_align") i32 (i32.const 4))) + `, "tls-immutable-base", undefined, 0, 16); + expect(() => loadSharedLibrarySync( + "libtls-immutable-base.so", + immutableBase, + createSideForkLoadOptions(), + )).toThrow(/must be mutable/); + + const live = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "__memory_base" (global $memory_base i32)) + (global $tls_base (export "__tls_base") (mut i32) (i32.const 0)) + (global (export "__tls_size") i32 (i32.const 4)) + (global (export "__tls_align") i32 (i32.const 4)) + (func $start + global.get $memory_base + i32.const 8 + i32.add + global.set $tls_base) + (start $start)) + `, "tls-outside-reservation", undefined, 0, 16); + const parent = createSideForkLoadOptions(); + const loaded = loadSharedLibrarySync("libtls-outside.so", live, parent); + const child = createSideForkLoadOptions(); + new Uint8Array(child.memory.buffer).set(new Uint8Array(parent.memory.buffer)); + expect(() => loadSharedLibrarySync("libtls-outside.so", live, child, { + memoryBase: loaded.memoryBase, + tableBase: loaded.tableBase, + tlsBase: loaded.memoryBase + 16, + })).toThrow(/escapes module reservation/); + }); + + it("pads replay to the exact parent table base and rejects overshoot", () => { + const wasmBytes = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "value") (result i32) i32.const 9)) + `, "replay-table-base"); + const parent = createSideForkLoadOptions(); + parent.table.grow(4); + const parentLib = loadSharedLibrarySync("liblayout.so", wasmBytes, parent); + expect(parentLib.tableBase).toBe(5); + + const child = createSideForkLoadOptions(); + const childLib = loadSharedLibrarySync("liblayout.so", wasmBytes, child, { + memoryBase: parentLib.memoryBase, + tableBase: parentLib.tableBase, + }); + expect(childLib.tableBase).toBe(parentLib.tableBase); + expect(child.table.length).toBe(parent.table.length); + + const overshot = createSideForkLoadOptions(); + overshot.table.grow(parentLib.tableBase); + expect(() => loadSharedLibrarySync("liblayout.so", wasmBytes, overshot, { + memoryBase: parentLib.memoryBase, + tableBase: parentLib.tableBase, + })).toThrow(/past parent base/); + }); + + it("clears failed-load table entries and records the surviving gap", () => { + const options = createSideForkLoadOptions(); + const deallocated: Array<{ addr: number; size: number }> = []; + options.allocateMemory = () => 0x2000; + options.deallocateMemory = (addr, size) => deallocated.push({ addr, size }); + const invalid = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (import "env" "missing" (func $missing)) + (func (export "never") call $missing)) + `, "failed-table-growth", undefined, 2, 16); + expect(() => loadSharedLibrarySync("libfailed.so", invalid, options)).toThrow(); + expect(options.table.length).toBe(3); + expect(options.table.get(1)).toBeNull(); + expect(options.table.get(2)).toBeNull(); + expect(options.loadedLibraries.size).toBe(0); + expect(deallocated).toEqual([{ addr: 0x2000, size: 16 }]); + + const valid = buildDylinkWat(` + (module + (import "env" "memory" (memory 1 100 shared)) + (func (export "survivor") (result i32) i32.const 1)) + `, "surviving-after-failure"); + const survivor = loadSharedLibrarySync("libsurvivor.so", valid, options); + expect(survivor.tableBase).toBe(3); + + const child = createSideForkLoadOptions(); + const replayed = loadSharedLibrarySync("libsurvivor.so", valid, child, { + memoryBase: survivor.memoryBase, + tableBase: survivor.tableBase, + }); + expect(replayed.tableBase).toBe(3); + expect(child.table.length).toBe(options.table.length); + }); }); describe.skipIf(!hasCompiler())("DynamicLinker", () => { @@ -375,3 +1166,117 @@ describe.skipIf(!hasCompiler())("DynamicLinker", () => { expect(h1).toBe(h2); }); }); + +function hasWat2Wasm(): boolean { + try { + execFileSync("wat2wasm", ["--version"], { stdio: "ignore" }); + return true; + } catch { + return false; + } +} + +// A minimal dylink.0 section (MEM_INFO: mem/table size + align all 0), which +// marks a module as a side module. wat2wasm emits custom sections last, but the +// loader requires dylink.0 first, so it is injected as raw bytes below rather +// than declared in the WAT. +const DYLINK_SECTION = new Uint8Array([ + 0x00, 0x0f, // custom section, size 15 + 0x08, 0x64, 0x79, 0x6c, 0x69, 0x6e, 0x6b, 0x2e, 0x30, // name "dylink.0" + 0x01, 0x04, 0x00, 0x00, 0x00, 0x00, // MEM_INFO subsection +]); + +/** + * Assemble a hand-written side module. Using WAT (rather than a compiled C/C++ + * fixture) lets these tests reproduce the exact import shapes a real C++ side + * module produces — a self-defined symbol that is *also* an env import, and an + * env.__cpp_exception tag — which no self-contained C fixture can emit. + */ +function assembleSideModule(wat: string, name: string): Uint8Array { + const dir = join(tmpdir(), "wasm-dylink-test"); + mkdirSync(dir, { recursive: true }); + const watPath = join(dir, `${name}.wat`); + const wasmPath = join(dir, `${name}.wasm`); + writeFileSync(watPath, wat); + execFileSync("wat2wasm", ["--enable-exceptions", watPath, "-o", wasmPath], + { stdio: "pipe" }); + const raw = new Uint8Array(readFileSync(wasmPath)); + const out = new Uint8Array(8 + DYLINK_SECTION.length + (raw.length - 8)); + out.set(raw.subarray(0, 8), 0); // magic + version + out.set(DYLINK_SECTION, 8); // dylink.0 first + out.set(raw.subarray(8), 8 + DYLINK_SECTION.length); + return out; +} + +describe.skipIf(!hasWat2Wasm())("weak self-import handling", () => { + function createLoadOptions(): LoadSharedLibraryOptions { + return { + memory: new WebAssembly.Memory({ initial: 1, maximum: 100, shared: true }), + table: new WebAssembly.Table({ initial: 1, element: "anyfunc" }), + stackPointer: new WebAssembly.Global({ value: "i32", mutable: true }, 65536), + heapPointer: { value: 1024 }, + globalSymbols: new Map(), + got: new Map(), + loadedLibraries: new Map(), + }; + } + + it("routes an unresolved env import to the module's own export", () => { + // `self_fn` is both imported from env and exported: the shape wasm-ld emits + // for an interposable weak C++ symbol the module also defines. + const lib = loadSharedLibrarySync("self-import.so", assembleSideModule(` + (module + (import "env" "self_fn" (func $self_fn (result i32))) + (func (export "self_fn") (result i32) (i32.const 42)) + (func (export "call_self") (result i32) (call $self_fn))) + `, "self-import"), createLoadOptions()); + expect((lib.exports.call_self as Function)()).toBe(42); + }); + + it("rejects a genuinely absent env import during instantiation", () => { + // Only an import that the side module itself exports gets a trampoline. + // A real ABI gap must remain an eager load failure rather than hiding on + // an unexecuted path behind a delayed stub. + expect(() => loadSharedLibrarySync( + "missing-import.so", + assembleSideModule(` + (module + (import "env" "missing_fn" (func $missing (result i32))) + (func (export "call_missing") (result i32) (call $missing))) + `, "missing-import"), + createLoadOptions(), + )).toThrow(); + }); + + it("does not mistake an imported-function re-export for a definition", () => { + // A same-name export can point straight back at the imported function. + // Treating that shape as a definition would recurse forever through the + // host trampoline instead of rejecting the unresolved import at dlopen. + expect(() => loadSharedLibrarySync( + "reexported-import.so", + assembleSideModule(` + (module + (import "env" "reexported_fn" (func $reexported (result i32))) + (export "reexported_fn" (func $reexported)) + (func (export "call_reexported") (result i32) (call $reexported))) + `, "reexported-import"), + createLoadOptions(), + )).toThrow(); + }); + + it("provides the __cpp_exception tag to -fwasm-exceptions modules", () => { + // C++ side modules import this i32-payload tag. Exercise an actual + // throw/catch so both the supplied signature and engine behavior are + // covered, not merely WebAssembly.Instance's value-type check. + const lib = loadSharedLibrarySync("tag.so", assembleSideModule(` + (module + (import "env" "__cpp_exception" (tag $exc (param i32))) + (func (export "throw_and_catch") (result i32) + (try (result i32) + (do + (throw $exc (i32.const 42))) + (catch $exc)))) + `, "tag"), createLoadOptions()); + expect((lib.exports.throw_and_catch as Function)()).toBe(42); + }); +}); diff --git a/host/test/environment-lifecycle.test.ts b/host/test/environment-lifecycle.test.ts new file mode 100644 index 0000000000..cc25512fc1 --- /dev/null +++ b/host/test/environment-lifecycle.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const program = join(__dirname, "../../examples/environment_lifecycle_test.wasm"); + +describe("process environment lifecycle", () => { + it("keeps initial, forked, replacement, and empty environments coherent", async () => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["/bin/environment-lifecycle"], + env: ["INITIAL=parent", "REMOVE=before-fork"], + execPrograms: new Map([["/bin/environment-lifecycle", program]]), + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("FORK_ENV_PASS"); + expect(result.stdout).toContain("EXEC_ENV_PASS"); + expect(result.stdout).toContain("EMPTY_ENV_PASS"); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/exec-state-tracking.test.ts b/host/test/exec-state-tracking.test.ts new file mode 100644 index 0000000000..f79de7f816 --- /dev/null +++ b/host/test/exec-state-tracking.test.ts @@ -0,0 +1,1142 @@ +import { describe, expect, it, vi } from "vitest"; +import { + CentralizedKernelWorker, + isCurrentProcessGeneration, +} from "../src/kernel-worker"; +import { + ABI_SYSCALLS, + CH_ARG_SIZE, + CH_ARGS, + CH_DATA_SIZE, + CH_RETURN, + HOST_INTERCEPTED_SYSCALLS, +} from "../src/generated/abi"; + +describe("exec host-state transition", () => { + it("rejects an async continuation from a replaced process generation", () => { + const oldMemory = new WebAssembly.Memory({ initial: 1 }); + const newMemory = new WebAssembly.Memory({ initial: 1 }); + const oldGeneration = { memory: oldMemory }; + const newGeneration = { memory: newMemory }; + const processes = new Map([[7, oldGeneration]]); + + expect(isCurrentProcessGeneration( + processes, + 7, + oldGeneration, + oldMemory, + )).toBe(true); + processes.set(7, newGeneration); + expect(isCurrentProcessGeneration( + processes, + 7, + oldGeneration, + oldMemory, + )).toBe(false); + expect(isCurrentProcessGeneration( + processes, + 7, + newGeneration, + oldMemory, + )).toBe(false); + expect(isCurrentProcessGeneration( + processes, + 7, + newGeneration, + newMemory, + true, + )).toBe(false); + }); + + it("drops discarded-image async and thread-channel state", () => { + vi.useFakeTimers(); + try { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const otherMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const mainChannel = createChannel(7, memory, 0); + const threadChannel = createChannel(7, memory, 256); + const otherChannel = createChannel(8, otherMemory, 0); + const sleepTimer = setTimeout(() => {}, 60_000); + const threadSleepTimer = setTimeout(() => {}, 60_000); + const otherSleepTimer = setTimeout(() => {}, 60_000); + const worker = createWorker({ + processes: new Map([ + [7, { channels: [mainChannel, threadChannel], memory }], + [8, { channels: [otherChannel], memory: otherMemory }], + ]), + activeChannels: [mainChannel, threadChannel, otherChannel], + waitingForChild: [ + { parentPid: 7, channel: mainChannel }, + { parentPid: 8, channel: otherChannel }, + ], + pendingSleeps: new Map([ + [mainChannel, { timer: sleepTimer, channel: mainChannel }], + [threadChannel, { timer: threadSleepTimer, channel: threadChannel }], + [otherChannel, { timer: otherSleepTimer, channel: otherChannel }], + ]), + pendingFutexWaits: new Map([ + [threadChannel, { futexIndex: 4 }], + [otherChannel, { futexIndex: 5 }], + ]), + pendingCancels: new Set([threadChannel, otherChannel]), + stoppedPids: new Set([7, 8]), + parkedChannelCompletions: new Map([ + [mainChannel, { prepared: {}, relistenRequested: true }], + [otherChannel, { prepared: {}, relistenRequested: true }], + ]), + deferredStoppedChannels: new Map([ + [threadChannel, true], + [otherChannel, true], + ]), + channelTids: new Map([ + ["7:256", 11], + ["8:0", 8], + ]), + threadForkContexts: new Map([ + ["7:256", { fnPtr: 1, argPtr: 2 }], + ["8:0", { fnPtr: 3, argPtr: 4 }], + ]), + threadCtidPtrs: new Map([ + ["7:11", 0x1000], + ["8:8", 0x2000], + ]), + }); + const notify = vi.spyOn(Atomics, "notify"); + + worker.prepareProcessForExec(7); + + expect(worker.processes.has(7)).toBe(true); + expect(worker.processes.get(7).channels).toEqual([]); + expect(worker.isExecHandoffActive(7)).toBe(true); + expect(() => worker.addChannel(7, 512)).toThrow(/replacing its image/); + expect(worker.processes.has(8)).toBe(true); + expect(worker.activeChannels).toEqual([otherChannel]); + expect(worker.waitingForChild).toEqual([ + { parentPid: 8, channel: otherChannel }, + ]); + expect(worker.pendingSleeps.has(mainChannel)).toBe(false); + expect(worker.pendingSleeps.has(threadChannel)).toBe(false); + expect(worker.pendingSleeps.has(otherChannel)).toBe(true); + expect(worker.pendingFutexWaits.has(threadChannel)).toBe(false); + expect(worker.pendingFutexWaits.has(otherChannel)).toBe(true); + expect(worker.pendingCancels.has(threadChannel)).toBe(false); + expect(worker.pendingCancels.has(otherChannel)).toBe(true); + expect(worker.stoppedPids.has(7)).toBe(true); + expect(worker.stoppedPids.has(8)).toBe(true); + expect(worker.parkedChannelCompletions.has(mainChannel)).toBe(false); + expect(worker.parkedChannelCompletions.has(otherChannel)).toBe(true); + expect(worker.deferredStoppedChannels.has(threadChannel)).toBe(false); + expect(worker.deferredStoppedChannels.has(otherChannel)).toBe(true); + expect(worker.channelTids.has("7:256")).toBe(false); + expect(worker.channelTids.get("8:0")).toBe(8); + expect(worker.threadForkContexts.has("7:256")).toBe(false); + expect(worker.threadForkContexts.has("8:0")).toBe(true); + expect(worker.threadCtidPtrs.has("7:11")).toBe(false); + expect(worker.threadCtidPtrs.get("8:8")).toBe(0x2000); + expect(notify).toHaveBeenCalledWith( + expect.any(Int32Array), + 4, + 1, + ); + clearTimeout(otherSleepTimer); + } finally { + vi.restoreAllMocks(); + vi.useRealTimers(); + } + }); + + it("rejects an old-memory clone after replacement registration", () => { + const oldMemory = new WebAssembly.Memory({ initial: 1 }); + const newMemory = new WebAssembly.Memory({ initial: 1 }); + const worker = createWorker({ + processes: new Map([[7, { channels: [], memory: newMemory }]]), + }); + + expect(() => worker.addChannel(7, 512, 11, 1, 2, oldMemory)) + .toThrow(/changed memory generation/); + expect(worker.processes.get(7).channels).toEqual([]); + }); + + it("keeps concurrent sleeps independent across one process's threads", async () => { + vi.useFakeTimers(); + try { + const memory = new WebAssembly.Memory({ initial: 3, maximum: 3, shared: true }); + const mainChannel = createChannel(7, memory, 0); + const threadChannel = createChannel(7, memory, 0x10000); + const completeSleep = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { + channels: [mainChannel, threadChannel], + memory, + }]]), + completeSleepWithSignalCheck: completeSleep, + }); + + expect(worker.handleSleepDelay( + mainChannel, ABI_SYSCALLS.Usleep, [50_000], 0, 0, + )).toBe(true); + expect(worker.handleSleepDelay( + threadChannel, ABI_SYSCALLS.Usleep, [10_000], 0, 0, + )).toBe(true); + expect(worker.pendingSleeps.size).toBe(2); + + await vi.advanceTimersByTimeAsync(10); + expect(completeSleep).toHaveBeenCalledTimes(1); + expect(completeSleep).toHaveBeenLastCalledWith( + threadChannel, ABI_SYSCALLS.Usleep, [10_000], 0, 0, + ); + expect(worker.pendingSleeps.has(mainChannel)).toBe(true); + + await vi.advanceTimersByTimeAsync(40); + expect(completeSleep).toHaveBeenCalledTimes(2); + expect(completeSleep).toHaveBeenLastCalledWith( + mainChannel, ABI_SYSCALLS.Usleep, [50_000], 0, 0, + ); + expect(worker.pendingSleeps.size).toBe(0); + } finally { + vi.useRealTimers(); + } + }); + + it("drops a stale retry before consulting replacement process state", () => { + const oldMemory = new WebAssembly.Memory({ initial: 1 }); + const newMemory = new WebAssembly.Memory({ initial: 1 }); + const oldChannel = createChannel(7, oldMemory, 0); + const newChannel = createChannel(7, newMemory, 0); + const getProcessExitSignal = vi.fn(() => 11); + const handleProcessTerminated = vi.fn(); + const handleSyscall = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [newChannel], memory: newMemory }]]), + getProcessExitSignal, + handleProcessTerminated, + handleSyscall, + }); + + worker.retrySyscall(oldChannel); + + expect(getProcessExitSignal).not.toHaveBeenCalled(); + expect(handleProcessTerminated).not.toHaveBeenCalled(); + expect(handleSyscall).not.toHaveBeenCalled(); + }); + + it("does not wake a signal-dead image after async exec failure", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const channel = createChannel(7, memory, 0); + const handleProcessTerminated = vi.fn(); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + getProcessExitSignal: vi.fn(() => 11), + handleProcessTerminated, + completeChannel, + }); + + worker.finishFailedExec(channel, 211, [0, 0, 0], 3); + + expect(handleProcessTerminated).toHaveBeenCalledWith(channel); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("does not wake a normally reaped image after async exec failure", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const channel = createChannel(7, memory, 0); + const getProcessExitSignal = vi.fn(() => 0); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + hostReaped: new Set([7]), + getProcessExitSignal, + completeChannel, + }); + + worker.finishFailedExec(channel, 211, [0, 0, 0], 3); + + expect(getProcessExitSignal).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("does not create a spawn child after async resolution loses its parent channel", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + const bytes = new Uint8Array(memory.buffer); + const pathPtr = 0x100; + const path = new TextEncoder().encode("/bin/child"); + bytes.set(path, pathPtr); + const blobPtr = 0x200; + bytes.fill(0, blobPtr, blobPtr + 40); + let resolveProgram!: (value: ReturnType) => void; + const program = new Promise>((resolve) => { + resolveProgram = resolve; + }); + const kernelSpawn = vi.fn(() => 100); + const onSpawn = vi.fn(async () => 0); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { + onResolveSpawn: vi.fn(() => program), + onSpawn, + }, + completeChannel, + kernelInstance: { + exports: { kernel_spawn_process: kernelSpawn }, + }, + }); + + worker.handleSpawn(channel, [pathPtr, path.length, blobPtr, 40, 0, 0]); + worker.processes.get(7).channels = []; + resolveProgram(resolvedProgram()); + await Promise.resolve(); + await Promise.resolve(); + + expect(kernelSpawn).not.toHaveBeenCalled(); + expect(onSpawn).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("rejects an unlaunchable spawn before creating a child or applying file actions", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + const bytes = new Uint8Array(memory.buffer); + const pathPtr = 0x100; + const path = new TextEncoder().encode("/bin/malformed"); + bytes.set(path, pathPtr); + const blobPtr = 0x200; + bytes.fill(0, blobPtr, blobPtr + 40); + const kernelSpawn = vi.fn(() => 100); + const onSpawn = vi.fn(async () => 0); + const completeChannel = vi.fn(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { + onResolveSpawn: vi.fn(async () => ({ errno: 8 })), + onSpawn, + }, + completeChannel, + kernelInstance: { + exports: { kernel_spawn_process: kernelSpawn }, + }, + }); + + worker.handleSpawn(channel, [pathPtr, path.length, blobPtr, 40, 0, 0]); + await Promise.resolve(); + await Promise.resolve(); + + expect(kernelSpawn).not.toHaveBeenCalled(); + expect(onSpawn).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_SPAWN, + [pathPtr, path.length, blobPtr, 40, 0, 0], + undefined, + -1, + 8, + ); + }); + + it("keeps a created spawn child but suppresses stale parent completion", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + let finishSpawn!: (result: number) => void; + const spawned = new Promise((resolve) => { + finishSpawn = resolve; + }); + const kernelSpawn = vi.fn(() => 100); + const removeProcess = vi.fn(); + const completeChannel = vi.fn(); + const onSpawn = vi.fn(() => spawned); + const program = resolvedProgram(); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { onSpawn }, + completeChannel, + kernelMemory: new WebAssembly.Memory({ initial: 1 }), + scratchOffset: 0, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_spawn_process: kernelSpawn, + kernel_remove_process: removeProcess, + }, + }, + }); + + worker.handleSpawnAfterResolve( + channel, + [0, 0, 0, 40, 0, 0], + 7, + 0, + new Uint8Array(40), + 40, + program, + [], + ); + expect(onSpawn).toHaveBeenCalledWith(7, 100, program, []); + worker.processes.get(7).channels = []; + finishSpawn(0); + await Promise.resolve(); + await Promise.resolve(); + + expect(kernelSpawn).toHaveBeenCalled(); + expect(removeProcess).not.toHaveBeenCalled(); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("installs spawn-child listener mirrors before async worker launch", async () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const channel = createChannel(7, memory, 0); + let finishSpawn!: (result: number) => void; + const spawned = new Promise((resolve) => { + finishSpawn = resolve; + }); + const close = vi.fn(); + const listener = { + server: { close }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + callbacks: { onSpawn: vi.fn(() => spawned) }, + completeChannel: vi.fn(), + kernelMemory: new WebAssembly.Memory({ initial: 1 }), + scratchOffset: 0, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_spawn_process: () => 100, + kernel_remove_process: vi.fn(), + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => + fd === 4 ? 41 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ + pid: 7, + fd: 4, + acceptWakeIdx: 41, + }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + }); + + worker.handleSpawnAfterResolve( + channel, + [0, 0, 0, 40, 0, 0], + 7, + 0, + new Uint8Array(40), + 40, + resolvedProgram(), + [], + ); + + expect(worker.tcpListenerTargets.get(8080)).toContainEqual({ + pid: 100, + fd: 4, + acceptWakeIdx: 41, + }); + worker.cleanupTcpListeners(7); + expect(close).not.toHaveBeenCalled(); + finishSpawn(0); + await Promise.resolve(); + }); + + it("drops a stale channel listener after the pid is re-registered", async () => { + const oldMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const newMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const oldChannel = createChannel(7, oldMemory, 0); + const newChannel = createChannel(7, newMemory, 0); + const worker = createWorker({ + processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), + activeChannels: [oldChannel], + usePolling: false, + relistenBatchSize: 64, + }); + const handleSyscall = vi.fn(); + worker.handleSyscall = handleSyscall; + + let wake!: (value: "ok") => void; + const waited = new Promise<"ok">((resolve) => { wake = resolve; }); + const waitAsync = vi.spyOn(Atomics, "waitAsync").mockReturnValue({ + async: true, + value: waited, + } as any); + try { + worker.listenOnChannel(oldChannel); + worker.processes.set(7, { channels: [newChannel], memory: newMemory }); + worker.activeChannels = [newChannel]; + wake("ok"); + await waited; + await Promise.resolve(); + + expect(waitAsync).toHaveBeenCalledTimes(1); + expect(handleSyscall).not.toHaveBeenCalled(); + + // Even if the discarded mailbox becomes pending later, entering the + // listener directly cannot dispatch it into the replacement process. + Atomics.store(oldChannel.i32View, 0, 1); + worker.listenOnChannel(oldChannel); + expect(handleSyscall).not.toHaveBeenCalled(); + } finally { + waitAsync.mockRestore(); + } + }); + + it("keeps replacement retry state when an old-generation timer fires", () => { + vi.useFakeTimers(); + try { + const oldMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const newMemory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const oldChannel = createChannel(7, oldMemory, 0); + const newChannel = createChannel(7, newMemory, 0); + const worker = createWorker({ + processes: new Map([[7, { channels: [oldChannel], memory: oldMemory }]]), + kernelInstance: { exports: {} }, + profileData: null, + }); + worker.retrySyscall = vi.fn(); + + worker.handleBlockingRetry(oldChannel, 999, [0, 0, 0, 0, 0, 0]); + vi.advanceTimersByTime(5); + + worker.processes.set(7, { channels: [newChannel], memory: newMemory }); + worker.handleBlockingRetry(newChannel, 999, [0, 0, 0, 0, 0, 0]); + expect(worker.pendingPollRetries.has(oldChannel)).toBe(true); + expect(worker.pendingPollRetries.has(newChannel)).toBe(true); + + vi.advanceTimersByTime(5); + expect(worker.pendingPollRetries.has(oldChannel)).toBe(false); + expect(worker.pendingPollRetries.has(newChannel)).toBe(true); + expect(worker.retrySyscall).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("accepts bounded metadata above 64 KiB and rejects truthful overflows", () => { + const worker = createWorker({}); + const aboveHistoricalLimit = Array.from({ length: 20 }, () => "x".repeat(4096)); + + expect(worker.validateExecMetadata(["program"], aboveHistoricalLimit)).toBe(0); + expect(worker.validateExecMetadata(["x".repeat(65_537)], [])).toBe(-7); + expect(worker.validateExecMetadata([], Array.from({ length: 1024 }, () => "x".repeat(4096)))) + .toBe(-7); + }); + + it("accounts ARG_MAX using the exec caller's pointer width", () => { + const worker = createWorker({}); + const nearBoundary = Array(8192).fill("x".repeat(504)); + + expect(worker.validateExecMetadata(nearBoundary, [], 4)).toBe(0); + expect(worker.validateExecMetadata(nearBoundary, [], 8)).toBe(-7); + }); + + it("reads long exec metadata without truncation and rejects oversized entries", () => { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); + const bytes = new Uint8Array(memory.buffer); + const view = new DataView(memory.buffer); + const arrayPtr = 0x100; + const stringPtr = 0x1000; + view.setUint32(arrayPtr, stringPtr, true); + view.setUint32(arrayPtr + 4, 0, true); + bytes.fill("a".charCodeAt(0), stringPtr, stringPtr + 5000); + bytes[stringPtr + 5000] = 0; + const worker = createWorker({}); + + const parsed = worker.readStringArrayFromProcess(bytes, arrayPtr, 4); + expect(parsed).toEqual({ values: ["a".repeat(5000)] }); + + bytes.fill("b".charCodeAt(0), stringPtr, stringPtr + 65_537); + bytes[stringPtr + 65_537] = 0; + expect(worker.readStringArrayFromProcess(bytes, arrayPtr, 4)).toEqual({ errno: 7 }); + }); + + it("accepts a pointer array whose terminator follows 1024 entries", () => { + const memory = new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + const bytes = new Uint8Array(memory.buffer); + const view = new DataView(memory.buffer); + const arrayPtr = 0x100; + const stringPtr = 0x2000; + bytes[stringPtr] = "x".charCodeAt(0); + bytes[stringPtr + 1] = 0; + for (let i = 0; i < 1024; i++) { + view.setUint32(arrayPtr + i * 4, stringPtr, true); + } + view.setUint32(arrayPtr + 1024 * 4, 0, true); + const worker = createWorker({}); + + const parsed = worker.readStringArrayFromProcess(bytes, arrayPtr, 4); + expect("values" in parsed && parsed.values).toHaveLength(1024); + }); + + it("rejects overlong or inaccessible exec paths instead of truncating them", () => { + const memory = new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); + const bytes = new Uint8Array(memory.buffer); + const pathPtr = 0x1000; + const worker = createWorker({}); + + bytes.fill("a".charCodeAt(0), pathPtr, pathPtr + 4095); + bytes[pathPtr + 4095] = 0; + expect(worker.readExecPathFromProcess(bytes, pathPtr)).toEqual({ + value: "a".repeat(4095), + }); + + bytes.fill("b".charCodeAt(0), pathPtr, pathPtr + 4096); + bytes[pathPtr + 4096] = 0; + expect(worker.readExecPathFromProcess(bytes, pathPtr)).toEqual({ errno: 36 }); + + bytes[bytes.byteLength - 1] = "c".charCodeAt(0); + expect(worker.readExecPathFromProcess(bytes, bytes.byteLength - 1)).toEqual({ errno: 14 }); + expect(worker.readExecPathFromProcess(bytes, 0)).toEqual({ errno: 14 }); + }); + + it("replaces metadata entry by entry and clears an empty environment", () => { + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const scratchOffset = 1024; + const clears: Array<[number, number]> = []; + const pushes: Array<{ pid: number; kind: number; bytes: Uint8Array }> = []; + const worker = createWorker({ + kernelMemory, + scratchOffset, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_clear_process_metadata: (pid: number, kind: number) => { + clears.push([pid, kind]); + return 0; + }, + kernel_push_process_metadata_entry: ( + pid: number, + kind: number, + ptr: number, + len: number, + ) => { + pushes.push({ + pid, + kind, + bytes: new Uint8Array(kernelMemory.buffer, ptr, len).slice(), + }); + if (pushes.length === 1) kernelMemory.grow(1); + return 0; + }, + }, + }, + }); + + worker.replaceProcessMetadata(7, 0, ["program", ""]); + worker.replaceProcessMetadata(7, 1, []); + + expect(clears).toEqual([[7, 0], [7, 1]]); + expect(pushes.map(entry => ({ + pid: entry.pid, + kind: entry.kind, + value: new TextDecoder().decode(entry.bytes), + }))).toEqual([ + { pid: 7, kind: 0, value: "program" }, + { pid: 7, kind: 0, value: "" }, + ]); + }); + + it("feature-detects metadata replacement and retains legacy small argv", () => { + const kernelMemory = new WebAssembly.Memory({ initial: 1 }); + const setArgv = vi.fn((_pid: number, _ptr: number, len: number) => { + expect(new TextDecoder().decode( + new Uint8Array(kernelMemory.buffer, 0, len), + )).toBe("program\0arg"); + return 0; + }); + const worker = createWorker({ + kernelMemory, + scratchOffset: 0, + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { kernel_set_process_argv: setArgv }, + }, + }); + + expect(worker.supportsExecMetadataReplacement()).toBe(false); + worker.replaceProcessMetadata(7, 0, ["program", "arg"]); + expect(setArgv).toHaveBeenCalled(); + expect(() => worker.replaceProcessMetadata(7, 1, [])) + .toThrow(/missing bounded process metadata exports/); + }); + + it("flushes file-backed mappings before commit and forgets them afterward", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const channel = { pid: 7, memory }; + const flush = vi.fn(() => true); + const worker = createWorker({ + processes: new Map([[7, { channels: [channel], memory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { fd: 4, fileOffset: 0x2000, len: 0x3000, writable: true }], + ])]]), + pwriteFromProcessMemory: flush, + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); + + expect(flush).toHaveBeenCalledWith(channel, 4, 0x1000, 0x3000, 0x2000); + expect(worker.sharedMappings.has(7)).toBe(true); + expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); + expect(worker.sharedMappings.has(7)).toBe(false); + }); + + it("retains mapping trackers when a pre-commit flush fails", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const worker = createWorker({ + processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { fd: 4, fileOffset: 0, len: 0x1000, writable: true }], + ])]]), + pwriteFromProcessMemory: vi.fn(() => false), + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(-5); + expect(worker.sharedMappings.has(7)).toBe(true); + }); + + it("does not flush read-only shared mappings during exec", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + const flush = vi.fn(() => false); + const worker = createWorker({ + processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), + sharedMappings: new Map([[7, new Map([ + [0x1000, { fd: 4, fileOffset: 0, len: 0x1000, writable: false }], + ])]]), + pwriteFromProcessMemory: flush, + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); + expect(flush).not.toHaveBeenCalled(); + }); + + it("tracks mmap writeback only for kernel-classified writable regular fds", () => { + const worker = createWorker({ + kernelInstance: { + exports: { + kernel_fd_supports_mmap_writeback: (_pid: number, fd: number) => + fd === 4 ? 1 : 0, + }, + }, + }); + + expect(worker.fdSupportsMmapWriteback(7, 4)).toBe(true); + expect(worker.fdSupportsMmapWriteback(7, 5)).toBe(false); + }); + + it("reacquires pwrite scratch views after kernel memory growth", () => { + const processMemory = new WebAssembly.Memory({ initial: 2 }); + const kernelMemory = new WebAssembly.Memory({ initial: 2, maximum: 4 }); + const channel = { pid: 7, memory: processMemory }; + let calls = 0; + const worker = createWorker({ + currentHandlePid: 0, + kernelMemory, + scratchOffset: 0, + toKernelPtr: (value: number) => value, + bindKernelTidForChannel: vi.fn(), + kernelInstance: { + exports: { + kernel_handle_channel: () => { + const args = new DataView(kernelMemory.buffer); + const requested = Number(args.getBigInt64( + CH_ARGS + 2 * CH_ARG_SIZE, + true, + )); + kernelMemory.grow(1); + new DataView(kernelMemory.buffer).setBigInt64( + CH_RETURN, + BigInt(requested), + true, + ); + calls++; + }, + }, + }, + }); + + expect(worker.pwriteFromProcessMemory( + channel, + 4, + 0x1000, + CH_DATA_SIZE + 4, + 0, + )).toBe(true); + expect(calls).toBe(2); + expect(worker.currentHandlePid).toBe(0); + }); + + it("copies SysV mappings before commit and detaches them afterward", () => { + const memory = new WebAssembly.Memory({ initial: 1 }); + new Uint8Array(memory.buffer, 0x1000, 4).set([1, 2, 3, 4]); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const writeChunk = vi.fn(() => 4); + const readChunk = vi.fn((_id: number, _offset: number, outPtr: number, len: number) => { + new Uint8Array(kernelMemory.buffer, outPtr, len).fill(0); + return len; + }); + const detach = vi.fn(() => 0); + const worker = createWorker({ + processes: new Map([[7, { channels: [{ pid: 7, memory }], memory }]]), + shmMappings: new Map([[7, new Map([ + [0x1000, { + segId: 3, + size: 4, + readOnly: false, + snapshot: new Uint8Array(4), + seenVersion: 0, + }], + ])]]), + shmSegmentVersions: new Map([[3, 0]]), + currentHandlePid: 0, + kernelMemory, + scratchOffset: 0, + getKernelMem: () => new Uint8Array(kernelMemory.buffer), + toKernelPtr: (value: number) => value, + kernelInstance: { + exports: { + kernel_set_current_pid: vi.fn(), + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_write_chunk: writeChunk, + kernel_ipc_shmdt: detach, + }, + }, + }); + + expect(worker.prepareAddressSpaceForExec(7)).toBe(0); + expect(writeChunk).toHaveBeenCalledWith(3, 0, 72, 4); + expect(detach).not.toHaveBeenCalled(); + expect(worker.shmMappings.has(7)).toBe(true); + + expect(worker.finalizeAddressSpaceForExec(7)).toBe(0); + expect(detach).toHaveBeenCalledWith(3); + expect(worker.shmMappings.has(7)).toBe(false); + }); + + it("validates the caller before setup and prunes closed epoll mirrors", () => { + let ambientPid = 0; + let preparedCaller = 0; + const openFds = new Set([6, 8]); + const worker = createWorker({ + currentHandlePid: 0, + kernelInstance: { + exports: { + kernel_exec_prepare: (_pid: number, tid: number) => { + ambientPid = worker.currentHandlePid; + preparedCaller = tid; + return 0; + }, + kernel_exec_setup_for_thread: (_pid: number, _tid: number) => { + ambientPid = worker.currentHandlePid; + return 0; + }, + kernel_exec_setup: () => 0, + kernel_fd_is_open: (_pid: number, fd: number) => openFds.has(fd) ? 1 : 0, + }, + }, + epollInterests: new Map([ + ["7:6", [ + { fd: 8, events: 1, data: 11n }, + { fd: 9, events: 1, data: 12n }, + ]], + ["7:10", []], + ]), + }); + + expect(worker.kernelExecPrepare(7, 11)).toBe(0); + expect(preparedCaller).toBe(11); + expect(ambientPid).toBe(7); + expect(worker.currentHandlePid).toBe(0); + expect(worker.kernelExecSetup(7, 11)).toBe(0); + expect(ambientPid).toBe(7); + expect(worker.currentHandlePid).toBe(0); + expect(worker.epollInterests.get("7:6")).toEqual([ + { fd: 8, events: 1, data: 11n }, + ]); + expect(worker.epollInterests.has("7:10")).toBe(false); + }); + + it("remaps a TCP listener mirror to its surviving fd alias", () => { + let committed = false; + const close = vi.fn(); + const listener = { + server: { close }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + currentHandlePid: 0, + kernelInstance: { + exports: { + kernel_exec_setup_for_thread: () => { + committed = true; + return 0; + }, + kernel_exec_setup: () => 0, + kernel_fd_is_open: (_pid: number, fd: number) => committed && fd === 2048 ? 1 : 0, + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => { + if (fd === 2048) return 41; + return !committed && fd === 4 ? 41 : -1; + }, + kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => + committed && wakeIdx === 41 ? 2048 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ pid: 7, fd: 4 }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + }); + + expect(worker.kernelExecSetup(7, 7)).toBe(0); + expect(worker.tcpListenerTargets.get(8080)).toEqual([{ pid: 7, fd: 2048 }]); + expect(worker.tcpListeners.has("7:4")).toBe(false); + expect(worker.tcpListeners.get("7:2048")).toEqual(listener); + expect(close).not.toHaveBeenCalled(); + }); + + it("remaps a listener after its original mirrored fd was already closed", () => { + const listener = { + server: { close: vi.fn() }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + currentHandlePid: 0, + kernelInstance: { + exports: { + kernel_exec_setup_for_thread: () => 0, + kernel_exec_setup: () => 0, + kernel_fd_is_open: (_pid: number, fd: number) => fd === 2048 ? 1 : 0, + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => + fd === 2048 ? 41 : -1, + kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => + wakeIdx === 41 ? 2048 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ + pid: 7, + fd: 4, + acceptWakeIdx: 41, + }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + }); + + expect(worker.kernelExecSetup(7, 7)).toBe(0); + expect(worker.tcpListenerTargets.get(8080)).toEqual([{ + pid: 7, + fd: 2048, + acceptWakeIdx: 41, + }]); + expect(worker.tcpListeners.get("7:2048")).toEqual(listener); + expect(listener.server.close).not.toHaveBeenCalled(); + }); + + it("keeps pending child listener targets during async worker launch", () => { + const targets = [ + { pid: 7, fd: 4 }, + { pid: 8, fd: 4 }, + ]; + const worker = createWorker({ + processes: new Map([[7, { channels: [], memory: new WebAssembly.Memory({ initial: 1 }) }]]), + tcpListenerTargets: new Map([[8080, targets]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + }); + + expect(worker.pickListenerTarget(8080)).toEqual({ pid: 7, fd: 4 }); + expect(worker.tcpListenerTargets.get(8080)).toEqual(targets); + }); + + it("reconciles a reused listener fd without losing its surviving alias", () => { + const listener = { + server: { close: vi.fn() }, + pid: 7, + port: 8080, + connections: new Set(), + }; + const worker = createWorker({ + kernelInstance: { + exports: { + kernel_get_fd_accept_wake_idx: (_pid: number, fd: number) => + fd === 4 ? 99 : fd === 6 ? 41 : -1, + kernel_find_listener_fd_by_accept_wake: (_pid: number, wakeIdx: number) => + wakeIdx === 41 ? 6 : wakeIdx === 99 ? 4 : -1, + }, + }, + tcpListenerTargets: new Map([[8080, [{ + pid: 7, + fd: 4, + acceptWakeIdx: 41, + }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpListeners: new Map([["7:4", listener]]), + netModule: null, + }); + + worker.startTcpListener(7, 4, 9090); + + expect(worker.tcpListenerTargets.get(8080)).toEqual([{ + pid: 7, + fd: 6, + acceptWakeIdx: 41, + }]); + expect(worker.tcpListenerTargets.get(9090)).toEqual([{ + pid: 7, + fd: 4, + acceptWakeIdx: 99, + }]); + expect(worker.tcpListeners.get("7:6")).toEqual(listener); + expect(listener.server.close).not.toHaveBeenCalled(); + }); + + it("finalizes signal death during the exec handoff exactly once", () => { + const notifyParent = vi.fn(); + const onExit = vi.fn(); + const worker = createWorker({ + hostReaped: new Set(), + callbacks: { onExit }, + notifyParentOfExitedProcess: notifyParent, + kernelInstance: { + exports: { + kernel_get_process_exit_signal: () => 15, + }, + }, + sharedMappings: new Map([[7, new Map([[0x1000, { fd: 4 }]])]]), + }); + + expect(worker.finalizeExecHandoffTermination(7)).toBe(15); + expect(worker.finalizeExecHandoffTermination(7)).toBe(15); + expect(notifyParent).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledTimes(1); + expect(onExit).toHaveBeenCalledWith(7, 143); + expect(worker.sharedMappings.has(7)).toBe(false); + }); + + it("fails when the required process-exit-signal export is absent", () => { + const worker = createWorker({ + kernelInstance: { + exports: { + kernel_get_process_exit_signal: undefined, + }, + }, + }); + + expect(() => worker.finalizeExecHandoffTermination(7)).toThrow( + "Kernel missing required kernel_get_process_exit_signal export", + ); + }); + + it("does not launch a signal-dead pending child or roll back its zombie", () => { + const notifyParent = vi.fn(); + const onExit = vi.fn(); + const cleanupTcpListeners = vi.fn(); + const removeProcess = vi.fn(); + const worker = createWorker({ + hostReaped: new Set(), + callbacks: { onExit }, + notifyParentOfExitedProcess: notifyParent, + cleanupTcpListeners, + kernelInstance: { + exports: { + kernel_get_process_exit_signal: (pid: number) => { + if (pid === 8) return 9; + if (pid === 9) return -1; + if (pid === 10) return 0; + return -3; + }, + kernel_remove_process: removeProcess, + }, + }, + epollInterests: new Map([ + ["8:4", [{ fd: 6, events: 1, data: 1n }]], + ["9:4", [{ fd: 6, events: 1, data: 2n }]], + ["10:4", [{ fd: 6, events: 1, data: 3n }]], + ["11:4", [{ fd: 6, events: 1, data: 4n }]], + ]), + }); + + expect(worker.shouldLaunchPendingChild(8)).toBe(false); + expect(worker.shouldLaunchPendingChild(9)).toBe(true); + expect(worker.shouldLaunchPendingChild(10)).toBe(false); + expect(worker.shouldLaunchPendingChild(11)).toBe(false); + expect(notifyParent).toHaveBeenCalledWith(8); + expect(onExit).toHaveBeenCalledWith(8, 137); + expect(cleanupTcpListeners.mock.calls).toEqual([[8], [10], [11]]); + expect(worker.epollInterests.has("8:4")).toBe(false); + expect(worker.epollInterests.has("9:4")).toBe(true); + expect(worker.epollInterests.has("10:4")).toBe(false); + expect(worker.epollInterests.has("11:4")).toBe(false); + expect(removeProcess).not.toHaveBeenCalled(); + }); +}); + +function createWorker(overrides: Record): any { + const worker = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map(), + activeChannels: [], + execHandoffPids: new Set(), + waitingForChild: [], + pendingSleeps: new Map(), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + pendingFutexWaits: new Map(), + pendingCancels: new Set(), + stoppedPids: new Set(), + parkedChannelCompletions: new Map(), + deferredStoppedChannels: new Map(), + socketTimeoutTimers: new Map(), + posixTimers: new Map(), + channelTids: new Map(), + threadForkContexts: new Map(), + threadCtidPtrs: new Map(), + sharedMappings: new Map(), + shmMappings: new Map(), + epollInterests: new Map(), + tcpListenerTargets: new Map(), + tcpListenerRRIndex: new Map(), + tcpVirtualListenerKeys: new Map(), + tcpListeners: new Map(), + tcpConnections: new Map(), + hostReaped: new Set(), + callbacks: {}, + io: { network: undefined }, + ...overrides, + }); + const kernelInstance = worker.kernelInstance ?? { exports: {} }; + worker.kernelInstance = { + ...kernelInstance, + exports: { + kernel_get_process_exit_signal: vi.fn(() => -1), + ...(kernelInstance.exports ?? {}), + }, + }; + return worker; +} + +function resolvedProgram() { + const programBytes = Uint8Array.from([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + ]).buffer; + return { + programBytes, + programModule: new WebAssembly.Module(programBytes), + argv: [], + }; +} + +function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset: number): any { + return { + pid, + memory, + channelOffset, + i32View: new Int32Array(memory.buffer, channelOffset), + consecutiveSyscalls: 0, + }; +} diff --git a/host/test/exec.test.ts b/host/test/exec.test.ts index 5726624ada..34743b9798 100644 --- a/host/test/exec.test.ts +++ b/host/test/exec.test.ts @@ -2,6 +2,9 @@ * Tests for execve support — loading a new program binary into an existing process. */ import { describe, it, expect } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { runCentralizedProgram } from "./centralized-test-helper"; import { tryResolveBinary } from "../src/binary-resolver"; @@ -59,4 +62,35 @@ describe("execve", () => { expect(result.stdout).toContain("argv[1]=from-fork"); expect(result.stdout).toContain("FROM=fork"); }); + + it.skipIf(!hasExecCaller)("rejects malformed Wasm before committing exec", async () => { + const tempDir = mkdtempSync(join(tmpdir(), "kandelo-exec-malformed-wasm-")); + const malformedWasm = join(tempDir, "malformed.wasm"); + try { + // Valid Wasm magic/version with a truncated type section. This reaches + // compilation, which must fail before kernelExecSetup discards the old + // process image. + writeFileSync(malformedWasm, Buffer.from([ + 0x00, 0x61, 0x73, 0x6d, + 0x01, 0x00, 0x00, 0x00, + 0x01, 0x01, 0xff, + ])); + + const result = await runCentralizedProgram({ + programPath: execCallerBinary!, + argv: ["exec-caller"], + timeout: 15_000, + execPrograms: new Map([ + ["/bin/exec-child", malformedWasm], + ]), + }); + + expect(result.exitCode).toBe(127); + expect(result.stderr).toContain("Exec format error"); + expect(result.stderr).not.toContain("Centralized worker failed"); + expect(result.stderr).not.toContain("WebAssembly.compile()"); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/host/test/fetch-backend.test.ts b/host/test/fetch-backend.test.ts index aae5ab0507..21610509a4 100644 --- a/host/test/fetch-backend.test.ts +++ b/host/test/fetch-backend.test.ts @@ -4,6 +4,7 @@ import { TlsNetworkBackend, type TlsMitmConnection } from "../src/networking/tls const encoder = new TextEncoder(); const decoder = new TextDecoder(); +const MSG_PEEK = 0x0002; afterEach(() => { vi.restoreAllMocks(); @@ -83,13 +84,25 @@ class LoopbackMitmTls implements TlsMitmConnection { async close(): Promise {} } +async function waitForReadable( + backend: Pick, + handle: number, +): Promise { + const deadline = Date.now() + 1_000; + while (Date.now() < deadline) { + if ((backend.poll(handle, 0x0001) & 0x0001) !== 0) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error("timed out waiting for readable poll"); +} + describe("FetchNetworkBackend", () => { afterEach(() => { vi.restoreAllMocks(); }); describe("getaddrinfo", () => { - it("returns a 4-byte address for any hostname", () => { + it("returns a 4-byte address for DNS names that can be deferred to fetch", () => { const backend = new FetchNetworkBackend(); const addr = backend.getaddrinfo("example.com"); expect(addr.length).toBe(4); @@ -102,6 +115,42 @@ describe("FetchNetworkBackend", () => { const addr2 = backend.getaddrinfo("example.com"); expect(addr1).toEqual(addr2); }); + + it("returns numeric IPv4 literals without synthesizing a DNS address", () => { + const backend = new FetchNetworkBackend(); + expect(Array.from(backend.getaddrinfo("2130706433"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.1"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.1.1"))).toEqual([127, 1, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.0.0.1"))).toEqual([127, 0, 0, 1]); + }); + + it("rejects malformed numeric IPv4 literals", () => { + const backend = new FetchNetworkBackend(); + expect(() => backend.getaddrinfo("4294967296")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("1..2")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("9999.9999.9999.9999")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("1.2.3.256")).toThrow("ENOENT"); + }); + + it("rejects syntactically invalid DNS names", () => { + const backend = new FetchNetworkBackend(); + expect(backend.getaddrinfo("example.com.")).toHaveLength(4); + expect(() => backend.getaddrinfo(".toto.toto.toto")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo(`www.${"x".repeat(100)}.com`)).toThrow("ENOENT"); + }); + + it("rejects the reserved invalid zone without rejecting unqualified names", () => { + const backend = new FetchNetworkBackend(); + expect(backend.getaddrinfo("dummy-host-name").length).toBe(4); + expect(() => backend.getaddrinfo("totes.invalid")).toThrow("ENOENT"); + }); + + it("allows explicitly aliased unqualified names", () => { + const backend = new FetchNetworkBackend({ + hostAliases: { registry: "registry.npmjs.org" }, + }); + expect(backend.getaddrinfo("registry").length).toBe(4); + }); }); describe("connect", () => { @@ -145,6 +194,32 @@ describe("FetchNetworkBackend", () => { }); }); + it("honors MSG_PEEK without consuming buffered response bytes", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("hello"))); + const backend = new FetchNetworkBackend(); + const addr = backend.getaddrinfo("example.com"); + backend.connect(1, addr, 80); + backend.send( + 1, + encoder.encode("GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"), + 0, + ); + + const first = decoder.decode(await recvWhenReady(backend, 1)); + expect(first).toContain("hello"); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("world"))); + backend.send( + 1, + encoder.encode("GET /2 HTTP/1.1\r\nHost: example.com\r\n\r\n"), + 0, + ); + await waitForReadable(backend, 1); + const peeked = decoder.decode(backend.recv(1, 4, MSG_PEEK)); + const consumed = decoder.decode(backend.recv(1, 4, 0)); + expect(peeked).toBe(consumed); + }); + describe("hostAliases", () => { it("rewrites the fetch target while preserving the request port", () => { const fetchMock = vi @@ -170,6 +245,44 @@ describe("FetchNetworkBackend", () => { }); describe("TlsNetworkBackend HTTP proxy path", () => { + describe("getaddrinfo", () => { + it("returns numeric IPv4 literals without synthesizing a DNS address", () => { + const backend = new TlsNetworkBackend(); + expect(Array.from(backend.getaddrinfo("2130706433"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.1"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.1.1"))).toEqual([127, 1, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.0.0.1"))).toEqual([127, 0, 0, 1]); + }); + + it("rejects malformed numeric IPv4 literals", () => { + const backend = new TlsNetworkBackend(); + expect(() => backend.getaddrinfo("4294967296")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("1..2")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("9999.9999.9999.9999")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("1.2.3.256")).toThrow("ENOENT"); + }); + + it("rejects syntactically invalid DNS names", () => { + const backend = new TlsNetworkBackend(); + expect(backend.getaddrinfo("example.com.")).toHaveLength(4); + expect(() => backend.getaddrinfo(".toto.toto.toto")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo(`www.${"x".repeat(100)}.com`)).toThrow("ENOENT"); + }); + + it("rejects special-use invalid but permits potentially resolvable unqualified names", () => { + const backend = new TlsNetworkBackend(); + expect(backend.getaddrinfo("dummy-host-name").length).toBe(4); + expect(() => backend.getaddrinfo("totes.invalid")).toThrow("ENOENT"); + }); + + it("allows explicitly aliased unqualified names", () => { + const backend = new TlsNetworkBackend({ + dnsAliases: { registry: "https://registry.npmjs.org" }, + }); + expect(backend.getaddrinfo("registry").length).toBe(4); + }); + }); + it("resets response state for keep-alive HTTP requests", async () => { let resolveSecond!: (response: Response) => void; const secondResponse = new Promise((resolve) => { @@ -219,10 +332,56 @@ describe("TlsNetworkBackend HTTP proxy path", () => { expect(response.toLowerCase()).not.toContain("content-encoding"); expect(response.toLowerCase()).not.toContain("connection: close"); }); + + it("routes HTTP fetches through the configured CORS proxy", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("proxied")); + vi.stubGlobal("fetch", fetchMock); + const proxyPrefix = "https://kandelo.test/proxy?url="; + const backend = new TlsNetworkBackend({ + corsProxyUrl: proxyPrefix, + dnsAliases: {}, + }); + const addr = backend.getaddrinfo("example.com"); + backend.connect(1, addr, 80); + + backend.send( + 1, + encoder.encode("GET /resource HTTP/1.1\r\nHost: example.com\r\n\r\n"), + 0, + ); + await recvWhenReady(backend, 1); + + expect(fetchMock).toHaveBeenCalledWith( + `${proxyPrefix}${encodeURIComponent("http://example.com/resource")}`, + expect.any(Object), + ); + }); + + it("honors MSG_PEEK without consuming HTTP response bytes", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("peek-body"))); + const backend = new TlsNetworkBackend(); + const addr = backend.getaddrinfo("proxy.local"); + backend.connect(1, addr, 80); + + sendGet(backend, 1, "/peek"); + await recvWhenReady(backend, 1); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("second-body"))); + sendGet(backend, 1, "/peek2"); + await recvWhenReady(backend, 1); + + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("third-body"))); + sendGet(backend, 1, "/peek3"); + const peeked = decoder.decode((await recvWhenReady({ + recv: (handle, maxLen) => backend.recv(handle, maxLen, MSG_PEEK), + }, 1)).subarray(0, 8)); + const consumed = decoder.decode(backend.recv(1, 8, 0)); + expect(peeked).toBe(consumed); + }); }); describe("TlsNetworkBackend TLS MITM path", () => { - it("delivers the full response to recv() before reporting EOF", async () => { + it("polls and peeks encrypted response bytes before reporting EOF", async () => { const body = "mitm-response-body"; vi.stubGlobal( "fetch", @@ -245,8 +404,39 @@ describe("TlsNetworkBackend TLS MITM path", () => { .getWriter() .write(encoder.encode("GET /readme HTTP/1.1\r\nHost: example.com\r\n\r\n")); - const response = decoder.decode(await recvWhenReady(backend, 1)); + await waitForReadable(backend, 1); + const peeked = backend.recv(1, 8, MSG_PEEK); + expect(backend.poll(1, 0x0001) & 0x0001).toBe(0x0001); + const consumed = backend.recv(1, 8, 0); + expect(peeked).toEqual(consumed); + const response = decoder.decode( + new Uint8Array([...consumed, ...await recvWhenReady(backend, 1)]), + ); expect(response).toContain("200"); expect(response).toContain(body); }); + + it("routes decrypted HTTPS requests through the configured CORS proxy", async () => { + const fetchMock = vi.fn().mockResolvedValue(new Response("proxied TLS")); + vi.stubGlobal("fetch", fetchMock); + const proxyPrefix = "https://kandelo.test/proxy?"; + let tls!: LoopbackMitmTls; + const backend = new TlsNetworkBackend({ + corsProxyUrl: proxyPrefix, + createTlsConnection: () => (tls = new LoopbackMitmTls()), + }); + await backend.init(); + + const addr = backend.getaddrinfo("example.com"); + backend.connect(2, addr, 443); + await tls.serverEnd.upstream.writable + .getWriter() + .write(encoder.encode("GET /secure HTTP/1.1\r\nHost: example.com\r\n\r\n")); + await waitForReadable(backend, 2); + + expect(fetchMock).toHaveBeenCalledWith( + `${proxyPrefix}https://example.com/secure`, + expect.any(Object), + ); + }); }); diff --git a/host/test/file-shared-memory.test.ts b/host/test/file-shared-memory.test.ts new file mode 100644 index 0000000000..cdc6a3467e --- /dev/null +++ b/host/test/file-shared-memory.test.ts @@ -0,0 +1,1401 @@ +import { describe, expect, it, vi } from "vitest"; +import { + ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, + CH_ERRNO, + CH_RETURN, + CH_STATUS, + CH_SYSCALL, + CHANNEL_STATUS_COMPLETE, +} from "../src/generated/abi"; +import { WasmPosixKernel } from "../src/kernel"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +const MAP_SHARED = 1; +const MAP_PRIVATE = 2; +const MAP_FIXED = 0x10; +const MAP_ANONYMOUS = 0x20; +const PROT_READ = 1; +const PROT_WRITE = 2; +const REGULAR_MODE = 0o100644; +const SYS_COPY_FILE_RANGE = 290; +const SYS_SPLICE = 291; + +function memory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function createFileHarness() { + const pids = [71, 72, 73]; + const memories = new Map(pids.map((pid) => [pid, memory()])); + const channels = new Map(pids.map((pid) => { + const processMemory = memories.get(pid)!; + return [pid, { + pid, + memory: processMemory, + channelOffset: 0, + i32View: new Int32Array(processMemory.buffer, 0, 1), + consecutiveSyscalls: 0, + }]; + })); + const storage = new Uint8Array(16 * 1024); + storage.set(Array.from({ length: 64 }, (_, i) => i + 1)); + let logicalSize = storage.length; + const fdHostHandles = new Map([[4, 100], [9, 101]]); + let nextHandle = 102; + const liveHandles = new Set(fdHostHandles.values()); + const open = vi.fn((_path: string, _flags: number) => { + const handle = nextHandle++; + liveHandles.add(handle); + return handle; + }); + const close = vi.fn((handle: number) => { + liveHandles.delete(handle); + return 0; + }); + const io = { + open, + close, + read: vi.fn((handle: number, out: Uint8Array, offset: number | null, len: number) => { + if (!liveHandles.has(handle)) throw new Error("closed stable handle"); + const start = offset ?? 0; + if (start >= logicalSize) return 0; + const count = Math.min(len, logicalSize - start); + out.set(storage.subarray(start, start + count)); + return count; + }), + write: vi.fn((handle: number, input: Uint8Array, offset: number | null, len: number) => { + if (!liveHandles.has(handle)) throw new Error("closed stable handle"); + const start = offset ?? 0; + const count = Math.min(len, storage.length - start); + storage.set(input.subarray(0, count), start); + logicalSize = Math.max(logicalSize, start + count); + return count; + }), + fstat: vi.fn((handle: number) => { + if (!liveHandles.has(handle)) throw new Error("closed stable handle"); + return { + dev: 7, + ino: 99, + mode: REGULAR_MODE, + nlink: 1, + uid: 0, + gid: 0, + size: logicalSize, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + }; + }), + stat: vi.fn((_path: string) => ({ + dev: 7, + ino: 99, + mode: REGULAR_MODE, + nlink: 1, + uid: 0, + gid: 0, + size: logicalSize, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + })), + fileIdentity: vi.fn((_path: string, dev: bigint, ino: bigint) => + ino === 0n ? null : `test:${dev}:${ino}`), + fileHandleIdentity: vi.fn((_handle: number, dev: bigint, ino: bigint) => + ino === 0n ? null : `test:${dev}:${ino}`), + }; + const kernel = new WasmPosixKernel( + { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, + io as any, + ); + const retainHostFileHandle = vi.spyOn(kernel, "retainHostFileHandle"); + const releaseHostFileHandle = vi.spyOn(kernel, "releaseHostFileHandle"); + const fdIdentity = new Map([ + [4, "/dev/shm/php-cache"], + [9, "/dev/shm/php-cache"], + ]); + const processes = new Map(pids.map((pid) => [pid, { + pid, + memory: memories.get(pid)!, + channels: [channels.get(pid)!], + }])); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + io, + kernel, + processes, + sharedMappings: new Map(), + anonymousSharedBackings: new Map(), + sharedMmapBackings: new Map(), + sharedMmapFdCache: new Map(), + shmMappings: new Map(), + shmSegmentVersions: new Map(), + fdSupportsMmapWriteback: vi.fn(() => true), + getFdAccessModeForSharedMapping: vi.fn(() => ({ kind: "ok", value: 2 })), + getFdStatForSharedMapping: vi.fn((_channel: unknown, fd: number) => { + return fdHostHandles.has(fd) + ? { + kind: "ok", + value: { + dev: 7n, + ino: 99n, + size: logicalSize, + mode: REGULAR_MODE, + hostHandle: fdHostHandles.get(fd)!, + }, + } + : { kind: "error", errno: 9 }; + }), + getFdPathForSharedMapping: vi.fn((_channel: unknown, fd: number) => + fdIdentity.has(fd) + ? { kind: "ok", value: fdIdentity.get(fd)! } + : { kind: "error", errno: 9 }), + }) as CentralizedKernelWorker; + + const mapResult = ( + pid: number, + fd: number, + addr: number, + len = 4096, + prot = PROT_WRITE, + ) => + (kw as any).mapSharedMmapFromFile( + channels.get(pid), + addr, + [0, len, prot, MAP_SHARED, fd, 0], + ) as { kind: "mapped" | "unsupported" | "error"; errno?: number }; + const map = ( + pid: number, + fd: number, + addr: number, + len = 4096, + prot = PROT_WRITE, + ) => mapResult(pid, fd, addr, len, prot).kind === "mapped"; + + return { + channels, + close, + fdHostHandles, + fdIdentity, + io, + kernel, + kw, + logicalSize: () => logicalSize, + map, + mapResult, + memories, + open, + pids, + releaseHostFileHandle, + retainHostFileHandle, + setLogicalSize: (size: number) => { logicalSize = size; }, + storage, + }; +} + +type FileHarness = ReturnType; + +function configureKernelSyscallHarness(h: FileHarness, pid: number) { + const kernelHandle = vi.fn(); + const completeChannel = vi.fn(); + Object.assign(h.kw as any, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), + scratchOffset: 0, + kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, + formatSyscallEntry: vi.fn(() => "memory syscall"), + synchronizeSharedMemoryForBoundary: vi.fn(), + flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), + completeChannel, + }); + return { completeChannel, kernelHandle }; +} + +function writeChannelSyscall( + channel: any, + syscallNr: number, + args: number[], +): void { + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, syscallNr, true); + for (let i = 0; i < args.length; i++) { + view.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i]), true); + } +} + +describe("file/POSIX MAP_SHARED page cache", () => { + it("reuses a prepared backing when registering the successful mmap", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const args = [0, 4096, PROT_WRITE, MAP_SHARED, 4, 0]; + const preparation = (h.kw as any).prepareSharedMmapFromFile( + h.channels.get(pid), + args, + ); + expect(preparation.kind).toBe("prepared"); + + expect((h.kw as any).registerPreparedSharedMmap( + h.channels.get(pid), + 0x1000, + preparation.context, + )).toEqual({ kind: "mapped" }); + expect(h.open).not.toHaveBeenCalled(); + expect(h.retainHostFileHandle).toHaveBeenCalledOnce(); + expect(h.retainHostFileHandle).toHaveBeenCalledWith(100); + expect((h.kw as any).getFdStatForSharedMapping).toHaveBeenCalledTimes(1); + expect((h.kw as any).getFdPathForSharedMapping).not.toHaveBeenCalled(); + expect((h.kw as any).getFdAccessModeForSharedMapping).toHaveBeenCalledTimes(1); + }); + + it("reserves a same-file backing across MAP_FIXED replacement cleanup", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const preparation = (h.kw as any).prepareSharedMmapFromFile( + h.channels.get(pid), + [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0], + ); + expect(preparation.kind).toBe("prepared"); + expect(backing.refCount).toBe(2); + + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(backing.refCount).toBe(1); + expect((h.kw as any).sharedMmapBackings.get(backing.key)).toBe(backing); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + + expect((h.kw as any).registerPreparedSharedMmap( + h.channels.get(pid), + addr, + preparation.context, + )).toEqual({ kind: "mapped" }); + expect(backing.refCount).toBe(1); + expect(h.open).not.toHaveBeenCalled(); + expect(h.retainHostFileHandle).toHaveBeenCalledTimes(1); + }); + + it("fails MAP_FIXED preflight before invoking the destructive kernel mmap", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + const originalMapping = { + fd: 9, + fileOffset: 0, + len: 4096, + writable: true, + }; + const originalMap = new Map([[addr, originalMapping]]); + (h.kw as any).sharedMappings.set(pid, originalMap); + h.retainHostFileHandle.mockImplementationOnce(() => { + throw Object.assign(new Error("handle retain failed"), { code: "EACCES" }); + }); + + const kernelHandle = vi.fn(); + const completeChannel = vi.fn(); + Object.assign(h.kw as any, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), + scratchOffset: 0, + kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, + formatSyscallEntry: vi.fn(() => "mmap"), + synchronizeSharedMemoryForBoundary: vi.fn(), + flushSharedMappingsBeforeFileSyscall: vi.fn(() => true), + completeChannel, + }); + + const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 4, 0]; + const view = new DataView(channel.memory.buffer, channel.channelOffset); + view.setUint32(CH_SYSCALL, ABI_SYSCALLS.Mmap, true); + for (let i = 0; i < args.length; i++) { + view.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, BigInt(args[i]), true); + } + + (h.kw as any)._handleSyscallInner(channel); + + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Mmap, + args, + undefined, + -1, + 13, + ); + expect((h.kw as any).sharedMappings.get(pid)).toBe(originalMap); + expect((h.kw as any).sharedMappings.get(pid).get(addr)).toBe(originalMapping); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + }); + + it("keeps MAP_FIXED intact when the old overlapping mapping cannot flush", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const originalMap = (h.kw as any).sharedMappings.get(pid); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const flush = vi.fn(() => false); + Object.assign(h.kw as any, { flushSharedMappings: flush }); + const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); + const args = [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0]; + writeChannelSyscall(channel, ABI_SYSCALLS.Mmap, args); + + (h.kw as any)._handleSyscallInner(channel); + + expect(flush).toHaveBeenCalledWith(channel, [addr, 65536]); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, ABI_SYSCALLS.Mmap, args, undefined, -1, 5, + ); + expect((h.kw as any).sharedMappings.get(pid)).toBe(originalMap); + expect(backing.refCount).toBe(1); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("fails MAP_FIXED before the kernel when process memory cannot cover it", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const oldAddr = 0x1000; + const fixedAddr = 0x10000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, oldAddr)).toBe(true); + const originalMap = (h.kw as any).sharedMappings.get(pid); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const flush = vi.fn(() => true); + Object.assign(h.kw as any, { flushSharedMappings: flush }); + const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); + const args = [fixedAddr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0]; + writeChannelSyscall(channel, ABI_SYSCALLS.Mmap, args); + + (h.kw as any)._handleSyscallInner(channel); + + expect(kernelHandle).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, ABI_SYSCALLS.Mmap, args, undefined, -1, 12, + ); + expect((h.kw as any).sharedMappings.get(pid)).toBe(originalMap); + expect(backing.refCount).toBe(1); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("releases a prepared reservation when pre-kernel MAP_FIXED work throws", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + Object.assign(h.kw as any, { + flushSharedMappings: vi.fn(() => { + throw new Error("pre-kernel flush threw"); + }), + }); + const { kernelHandle } = configureKernelSyscallHarness(h, pid); + writeChannelSyscall( + channel, + ABI_SYSCALLS.Mmap, + [addr, 4096, PROT_WRITE, MAP_SHARED | MAP_FIXED, 9, 0], + ); + + expect(() => (h.kw as any)._handleSyscallInner(channel)) + .toThrow(/pre-kernel flush threw/); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(backing.refCount).toBe(1); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("fails file mremap expansion before the kernel when a new page cannot load", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const originalMapping = (h.kw as any).sharedMappings.get(pid).get(addr); + h.io.read.mockImplementationOnce(() => { + throw new Error("extension read failed"); + }); + const { completeChannel, kernelHandle } = configureKernelSyscallHarness(h, pid); + const args = [addr, 4096, 8192, 1, 0, 0]; + writeChannelSyscall(channel, ABI_SYSCALLS.Mremap, args); + + (h.kw as any)._handleSyscallInner(channel); + + expect(kernelHandle).not.toHaveBeenCalled(); + expect(completeChannel).toHaveBeenCalledWith( + channel, ABI_SYSCALLS.Mremap, args, undefined, -1, 5, + ); + expect((h.kw as any).sharedMappings.get(pid).get(addr)).toBe(originalMapping); + expect(originalMapping.len).toBe(4096); + }); + + it("shares one inode backing across separate fds and preserves disjoint writes", () => { + const h = createFileHarness(); + const [firstPid, secondPid] = h.pids; + const firstAddr = 0x1000; + const secondAddr = 0x3000; + expect(h.map(firstPid, 4, firstAddr)).toBe(true); + expect(h.map(secondPid, 9, secondAddr)).toBe(true); + expect(h.open).not.toHaveBeenCalled(); + expect(h.retainHostFileHandle).toHaveBeenCalledTimes(1); + expect((h.kw as any).sharedMmapBackings.size).toBe(1); + + const first = new Uint8Array(h.memories.get(firstPid)!.buffer); + const second = new Uint8Array(h.memories.get(secondPid)!.buffer); + first[firstAddr + 11] = 0xa1; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(firstPid), + ); + second[secondAddr + 29] = 0xb2; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(secondPid), + ); + + expect(second[secondAddr + 11]).toBe(0xa1); + expect(second[secondAddr + 29]).toBe(0xb2); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(firstPid), + ); + expect(first[firstAddr + 29]).toBe(0xb2); + }); + + it("publishes a sole observer before a second mapping joins its backing", () => { + const h = createFileHarness(); + const [firstPid, secondPid] = h.pids; + const firstAddr = 0x1000; + const secondAddr = 0x3000; + expect(h.map(firstPid, 4, firstAddr)).toBe(true); + new Uint8Array(h.memories.get(firstPid)!.buffer)[firstAddr + 37] = 0xd7; + + expect(h.map(secondPid, 9, secondAddr)).toBe(true); + + expect(new Uint8Array(h.memories.get(secondPid)!.buffer)[secondAddr + 37]) + .toBe(0xd7); + }); + + it("converges overlapping aliases after one coherence boundary", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const firstAddr = 0x1000; + const secondAddr = 0x3000; + expect(h.map(pid, 4, firstAddr)).toBe(true); + expect(h.map(pid, 9, secondAddr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + process[firstAddr + 11] = 0xa1; + process[secondAddr + 29] = 0xb2; + + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + ); + + expect(process[firstAddr + 11]).toBe(0xa1); + expect(process[firstAddr + 29]).toBe(0xb2); + expect(process[secondAddr + 11]).toBe(0xa1); + expect(process[secondAddr + 29]).toBe(0xb2); + }); + + it("inherits file mappings across fork using the same stable backing", () => { + const h = createFileHarness(); + const [parentPid, , childPid] = h.pids; + const addr = 0x1800; + expect(h.map(parentPid, 4, addr)).toBe(true); + const parent = new Uint8Array(h.memories.get(parentPid)!.buffer); + parent[addr + 17] = 0x77; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(parentPid), + { force: true }, + ); + + h.kw.inheritProcessSharedMappings(parentPid, childPid); + const child = new Uint8Array(h.memories.get(childPid)!.buffer); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(child[addr + 17]).toBe(0x77); + expect(backing.refCount).toBe(2); + expect((h.kw as any).sharedMappings.get(childPid).size).toBe(1); + expect((h.kw as any).sharedMmapFdCache.has(`${childPid}:4`)).toBe(false); + }); + + it("retains an already-unlinked fd through close then munmap", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x2000; + // The pathname is gone before mmap, as in POSIX munmap/3-1 and 9-1. + h.fdIdentity.delete(4); + expect(h.map(pid, 4, addr)).toBe(true); + const stableHandle = Array.from((h.kw as any).sharedMmapBackings.values())[0].handle; + + // close(fd) asks the host to close the original handle, but the mapping's + // retain keeps it usable until the final munmap releases the backing. + expect((h.kernel as any).hostClose(BigInt(stableHandle))).toBe(0); + h.fdHostHandles.delete(4); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Close, [4], 0, 0, + ); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 23] = 0xc3; + (h.kw as any).flushSharedMappings(h.channels.get(pid), [addr, 4096]); + + expect(h.storage[23]).toBe(0xc3); + expect(h.close).not.toHaveBeenCalledWith(stableHandle); + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(h.close).toHaveBeenCalledWith(stableHandle); + }); + + it("releases munmap before a later close of the original fd", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x2000; + expect(h.map(pid, 4, addr)).toBe(true); + const stableHandle = Array.from((h.kw as any).sharedMmapBackings.values())[0].handle; + + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(h.releaseHostFileHandle).toHaveBeenCalledWith(stableHandle); + expect(h.close).not.toHaveBeenCalledWith(stableHandle); + + expect((h.kernel as any).hostClose(BigInt(stableHandle))).toBe(0); + expect(h.close).toHaveBeenCalledWith(stableHandle); + }); + + it("refreshes mappings after direct pwrite and ftruncate", () => { + const h = createFileHarness(); + const [writerPid, readerPid] = h.pids; + const writerAddr = 0x1000; + const readerAddr = 0x3000; + expect(h.map(writerPid, 4, writerAddr)).toBe(true); + expect(h.map(readerPid, 9, readerAddr)).toBe(true); + const writer = new Uint8Array(h.memories.get(writerPid)!.buffer); + const reader = new Uint8Array(h.memories.get(readerPid)!.buffer); + const sourcePtr = 0x7000; + writer.set([0xde, 0xad], sourcePtr); + h.storage.set([0xde, 0xad], 20); // kernel pwrite already changed the file + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(writerPid), + ABI_SYSCALLS.Pwrite, + [4, sourcePtr, 2, 20], + 2, + 0, + ); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(readerPid), + ); + expect(Array.from(reader.subarray(readerAddr + 20, readerAddr + 22))) + .toEqual([0xde, 0xad]); + + h.setLogicalSize(16); // kernel ftruncate already changed the file + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(writerPid), ABI_SYSCALLS.Ftruncate, [4, 16], 0, 0, + ); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(readerPid), + ); + expect(reader[readerAddr + 20]).toBe(0); + }); + + it("flushes mapped bytes before direct reads and reloads after direct writes", () => { + const h = createFileHarness(); + const [writerPid, readerPid] = h.pids; + const writerAddr = 0x1000; + const readerAddr = 0x3000; + expect(h.map(writerPid, 4, writerAddr)).toBe(true); + expect(h.map(readerPid, 9, readerAddr)).toBe(true); + const writer = new Uint8Array(h.memories.get(writerPid)!.buffer); + const reader = new Uint8Array(h.memories.get(readerPid)!.buffer); + + writer[writerAddr + 35] = 0xd1; + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(writerPid), ABI_SYSCALLS.Pread, [4], + )).toBe(true); + expect(h.storage[35]).toBe(0xd1); + + h.storage[41] = 0xe2; // the kernel's direct write already completed + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(writerPid), ABI_SYSCALLS.Write, [4], 1, 0, + ); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(readerPid), + ); + expect(reader[readerAddr + 41]).toBe(0xe2); + }); + + it("publishes dirty shared bytes before a private mmap reads the file", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 43] = 0xe3; + + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), + ABI_SYSCALLS.Mmap, + [0, 4096, PROT_READ, MAP_PRIVATE, 4, 0], + )).toBe(true); + + expect(h.storage[43]).toBe(0xe3); + }); + + it("keeps truncate, fallocate, and O_TRUNC coherent with mapped files", () => { + const path = "/dev/shm/php-cache"; + const pathBytes = new TextEncoder().encode(`${path}\0`); + + const truncate = createFileHarness(); + const truncatePid = truncate.pids[0]; + const truncateAddr = 0x1000; + const truncatePathPtr = 0x7000; + expect(truncate.map(truncatePid, 4, truncateAddr)).toBe(true); + const truncateMemory = new Uint8Array( + truncate.memories.get(truncatePid)!.buffer, + ); + truncateMemory.set(pathBytes, truncatePathPtr); + truncateMemory[truncateAddr + 47] = 0xa7; + expect((truncate.kw as any).flushSharedMappingsBeforeFileSyscall( + truncate.channels.get(truncatePid), + ABI_SYSCALLS.Truncate, + [truncatePathPtr, 16], + )).toBe(true); + expect(truncate.storage[47]).toBe(0xa7); + truncate.setLogicalSize(16); + (truncate.kw as any).handleSharedMappingsAfterFileSyscall( + truncate.channels.get(truncatePid), + ABI_SYSCALLS.Truncate, + [truncatePathPtr, 16], + 0, + 0, + ); + const truncateBacking = Array.from( + (truncate.kw as any).sharedMmapBackings.values(), + )[0]; + expect(truncateBacking.size).toBe(16); + + const fallocate = createFileHarness(); + const fallocatePid = fallocate.pids[0]; + expect(fallocate.map(fallocatePid, 4, 0x1000)).toBe(true); + new Uint8Array(fallocate.memories.get(fallocatePid)!.buffer)[0x1000 + 53] + = 0xb8; + expect((fallocate.kw as any).flushSharedMappingsBeforeFileSyscall( + fallocate.channels.get(fallocatePid), + ABI_SYSCALLS.Fallocate, + [4, 0, 0, 20_000], + )).toBe(true); + expect(fallocate.storage[53]).toBe(0xb8); + fallocate.setLogicalSize(20_000); + (fallocate.kw as any).handleSharedMappingsAfterFileSyscall( + fallocate.channels.get(fallocatePid), + ABI_SYSCALLS.Fallocate, + [4, 0, 0, 20_000], + 0, + 0, + ); + expect(Array.from((fallocate.kw as any).sharedMmapBackings.values())[0].size) + .toBe(20_000); + + const openTruncate = createFileHarness(); + const openPid = openTruncate.pids[0]; + const openPathPtr = 0x7000; + expect(openTruncate.map(openPid, 4, 0x1000)).toBe(true); + const openMemory = new Uint8Array(openTruncate.memories.get(openPid)!.buffer); + openMemory.set(pathBytes, openPathPtr); + openMemory[0x1000 + 59] = 0xc9; + expect((openTruncate.kw as any).flushSharedMappingsBeforeFileSyscall( + openTruncate.channels.get(openPid), + ABI_SYSCALLS.Open, + [openPathPtr, 0o1002, 0], + )).toBe(true); + expect(openTruncate.storage[59]).toBe(0xc9); + openTruncate.setLogicalSize(0); + (openTruncate.kw as any).handleSharedMappingsAfterFileSyscall( + openTruncate.channels.get(openPid), + ABI_SYSCALLS.Open, + [openPathPtr, 0o1002, 0], + 9, + 0, + ); + expect(Array.from( + (openTruncate.kw as any).sharedMmapBackings.values(), + )[0].size).toBe(0); + }); + + it("copies shared-memory pathnames before browser TextDecoder decoding", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const pathPtr = 0x7000; + const path = "/dev/shm/php-cache"; + new Uint8Array(h.memories.get(pid)!.buffer).set( + new TextEncoder().encode(`${path}\0`), + pathPtr, + ); + + const originalDecode = TextDecoder.prototype.decode; + const decode = vi.spyOn(TextDecoder.prototype, "decode").mockImplementation( + function (this: TextDecoder, input?: any, options?: any) { + if (ArrayBuffer.isView(input) + && input.buffer instanceof SharedArrayBuffer) { + throw new TypeError("browser TextDecoder rejects shared views"); + } + return originalDecode.call(this, input, options); + }, + ); + + try { + expect((h.kw as any).resolveSharedMmapPath( + h.channels.get(pid), + pathPtr, + )).toEqual({ kind: "ok", value: path }); + } finally { + decode.mockRestore(); + } + }); + + it("balances backing references across partial unmap splits", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr, 3 * 4096)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.refCount).toBe(1); + + (h.kw as any).cleanupSharedMappings(pid, addr + 4096, 4096); + expect(backing.refCount).toBe(2); + expect((h.kw as any).sharedMappings.get(pid).size).toBe(2); + + (h.kw as any).cleanupSharedMappings(pid, addr, 3 * 4096); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect(h.releaseHostFileHandle).toHaveBeenCalledTimes(1); + expect(h.close).not.toHaveBeenCalled(); + }); + + it("keeps one backing reference while mremap moves and grows a mapping", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const oldAddr = 0x1000; + const newAddr = 0x5000; + expect(h.map(pid, 4, oldAddr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + + (h.kw as any).remapSharedMapping(pid, oldAddr, newAddr, 8192); + expect(backing.refCount).toBe(1); + expect((h.kw as any).sharedMappings.get(pid).has(oldAddr)).toBe(false); + expect((h.kw as any).sharedMappings.get(pid).get(newAddr).len).toBe(8192); + expect(new Uint8Array(h.memories.get(pid)!.buffer)[newAddr]).toBe(1); + + (h.kw as any).cleanupSharedMappings(pid, newAddr, 8192); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + }); + + it("rolls back inherited file references when a later mapping is invalid", () => { + const h = createFileHarness(); + const [parentPid, , childPid] = h.pids; + const addr = 0x1000; + expect(h.map(parentPid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).sharedMappings.get(parentPid).set(addr + 0x2000, { + fd: 10, + fileOffset: 0, + len: 4096, + writable: true, + backingKind: "file", + backingKey: "missing", + snapshot: new Uint8Array(4096), + seenVersion: 0, + }); + + expect(() => h.kw.inheritProcessSharedMappings(parentPid, childPid)).toThrow(); + expect(backing.refCount).toBe(1); + expect((h.kw as any).sharedMappings.has(childPid)).toBe(false); + }); + + it("publishes file mappings before committing a kernel fork child", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).invalidateSharedMmapBackingPages(backing); + h.io.read.mockImplementation(() => { + throw new Error("fork publication failed"); + }); + const kernelForkProcess = vi.fn(() => 0); + Object.assign(h.kw as any, { + callbacks: { onFork: vi.fn() }, + kernelInstance: { exports: { kernel_fork_process: kernelForkProcess } }, + nextChildPid: 100, + }); + + expect(() => (h.kw as any).handleFork(channel, [])).toThrow( + /fork publication failed/, + ); + expect(kernelForkProcess).not.toHaveBeenCalled(); + }); + + it("reports handle-retention and initial-read failures without leaking a backing", () => { + const retainFailure = createFileHarness(); + retainFailure.retainHostFileHandle.mockImplementationOnce(() => { + throw new Error("retain failed"); + }); + expect(retainFailure.mapResult(retainFailure.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 5 }); + expect((retainFailure.kw as any).sharedMmapBackings.size).toBe(0); + + const readFailure = createFileHarness(); + readFailure.io.read.mockImplementationOnce(() => { + throw new Error("read failed"); + }); + expect(readFailure.mapResult(readFailure.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 5 }); + expect((readFailure.kw as any).sharedMmapBackings.size).toBe(0); + expect(readFailure.releaseHostFileHandle).toHaveBeenCalledWith(100); + expect(readFailure.close).not.toHaveBeenCalled(); + + const writeOnly = createFileHarness(); + (writeOnly.kw as any).getFdAccessModeForSharedMapping.mockReturnValue({ + kind: "ok", + value: 1, + }); + expect(writeOnly.mapResult(writeOnly.pids[0], 4, 0x1000, 4096, PROT_READ)) + .toEqual({ kind: "error", errno: 13 }); + expect(writeOnly.retainHostFileHandle).not.toHaveBeenCalled(); + }); + + it("preserves fd metadata errno values", () => { + const invalidFd = createFileHarness(); + expect(invalidFd.mapResult(invalidFd.pids[0], 77, 0x1000)) + .toEqual({ kind: "error", errno: 9 }); + expect(invalidFd.retainHostFileHandle).not.toHaveBeenCalled(); + + for (const errno of [24, 2, 30]) { + const h = createFileHarness(); + (h.kw as any).getFdStatForSharedMapping.mockReturnValueOnce({ + kind: "error", + errno, + }); + expect(h.mapResult(h.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno }); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect(h.retainHostFileHandle).not.toHaveBeenCalled(); + } + }); + + it("assembles backing pages across short positive reads", () => { + const h = createFileHarness(); + const read = h.io.read.getMockImplementation()!; + h.io.read.mockImplementation((handle, out, offset, len) => + read(handle, out, offset, Math.min(len, 17))); + + expect(h.map(h.pids[0], 4, 0x1000)).toBe(true); + + expect(h.io.read.mock.calls.length).toBeGreaterThan(1); + expect(Array.from( + new Uint8Array(h.memories.get(h.pids[0])!.buffer) + .subarray(0x1000, 0x1040), + )).toEqual(Array.from({ length: 64 }, (_, i) => i + 1)); + }); + + it("guards mprotect write upgrades with a lifetime-stable writable handle", () => { + const denied = createFileHarness(); + (denied.kw as any).fdSupportsMmapWriteback.mockReturnValue(false); + expect(denied.map(denied.pids[0], 4, 0x1000, 4096, PROT_READ)).toBe(true); + expect((denied.kw as any).prepareFileSharedMappingsForWrite( + denied.pids[0], 0x1000, 4096, + )).toBe(13); + expect(denied.retainHostFileHandle).toHaveBeenCalledWith(100); + + const allowed = createFileHarness(); + expect(allowed.map(allowed.pids[0], 4, 0x1000, 4096, PROT_READ)).toBe(true); + const stableHandle = Array.from( + (allowed.kw as any).sharedMmapBackings.values(), + )[0].handle; + // Simulate close(fd) followed by unlink/rename. The retained O_RDWR host + // handle must suffice; no pathname lookup or reopen is permitted. + expect((allowed.kernel as any).hostClose(BigInt(stableHandle))).toBe(0); + allowed.fdHostHandles.delete(4); + (allowed.kw as any).handleSharedMappingsAfterFileSyscall( + allowed.channels.get(allowed.pids[0]), ABI_SYSCALLS.Close, [4], 0, 0, + ); + allowed.fdIdentity.delete(4); + expect((allowed.kw as any).prepareFileSharedMappingsForWrite( + allowed.pids[0], 0x1000, 4096, + )).toBe(0); + const backing = Array.from((allowed.kw as any).sharedMmapBackings.values())[0]; + expect(backing.writable).toBe(true); + expect(allowed.open).not.toHaveBeenCalled(); + expect(allowed.close).not.toHaveBeenCalledWith(stableHandle); + }); + + it("replaces one retained O_RDONLY handle with a distinct O_RDWR handle", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + (h.kw as any).getFdAccessModeForSharedMapping.mockImplementation( + (_channel: unknown, fd: number) => ({ kind: "ok", value: fd === 4 ? 0 : 2 }), + ); + + expect(h.map(pid, 4, 0x1000, 4096, PROT_READ)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.handle).toBe(100); + expect(backing.writable).toBe(false); + + expect(h.map(pid, 9, 0x3000, 4096, PROT_READ)).toBe(true); + expect(backing.handle).toBe(101); + expect(backing.writable).toBe(true); + expect(h.retainHostFileHandle.mock.calls.map(([handle]) => handle)) + .toEqual([100, 101]); + expect(h.releaseHostFileHandle).toHaveBeenCalledWith(100); + expect(h.close).not.toHaveBeenCalledWith(100); + expect(h.open).not.toHaveBeenCalled(); + }); + + it("does not add a retain when a writable upgrade reports the same handle", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + let accessMode = 0; + (h.kw as any).getFdAccessModeForSharedMapping.mockImplementation( + () => ({ kind: "ok", value: accessMode }), + ); + + expect(h.map(pid, 4, 0x1000, 4096, PROT_READ)).toBe(true); + accessMode = 2; + expect(h.mapResult(pid, 4, 0x3000, 4096, PROT_READ)) + .toEqual({ kind: "error", errno: 5 }); + expect(h.retainHostFileHandle).toHaveBeenCalledTimes(1); + expect(h.releaseHostFileHandle).not.toHaveBeenCalled(); + }); + + it("retains negative fd identities until that descriptor is created", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const stat = (h.kw as any).getFdStatForSharedMapping; + const callsBefore = stat.mock.calls.length; + + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect(stat.mock.calls.length).toBe(callsBefore + 1); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Getpid, [], pid, 0, + ); + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect(stat.mock.calls.length).toBe(callsBefore + 1); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Open, [], 77, 0, + ); + expect((h.kw as any).findSharedMmapBackingForFd(h.channels.get(pid), 77)) + .toBeNull(); + expect(stat.mock.calls.length).toBe(callsBefore + 2); + }); + + it("preserves an unrelated dirty byte across a sub-page direct pwrite", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + process[addr + 11] = 0xa1; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + { force: true }, + ); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.dirtyPages.has(0)).toBe(true); + + const sourcePtr = 0x7000; + process[sourcePtr] = 0xb2; + h.storage[29] = 0xb2; + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 1, 29], 1, 0, + ); + + expect(backing.dirtyPages.has(0)).toBe(true); + expect(backing.pages.get(0)[11]).toBe(0xa1); + expect(backing.pages.get(0)[29]).toBe(0xb2); + expect((h.kw as any).flushSharedMmapBackingRange(backing, 0, 4096)).toBe(true); + expect(h.storage[11]).toBe(0xa1); + expect(h.storage[29]).toBe(0xb2); + }); + + it("clips msync and final unmap writeback to a 100-byte EOF", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + h.setLogicalSize(100); + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.size).toBe(100); + + process[addr + 50] = 0xa5; + process[addr + 150] = 0xf1; + expect((h.kw as any).flushSharedMappings( + h.channels.get(pid), [addr, 4096], + )).toBe(true); + expect(h.logicalSize()).toBe(100); + expect(h.storage[50]).toBe(0xa5); + expect(h.io.write.mock.calls.at(-1)?.[3]).toBe(100); + + process[addr + 60] = 0xb6; + process[addr + 160] = 0xf2; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + { force: true }, + ); + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + expect(h.logicalSize()).toBe(100); + expect(h.storage[60]).toBe(0xb6); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + }); + + it("refreshes authoritative size after direct extension and truncate", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const sourcePtr = 0x7000; + h.setLogicalSize(100); + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + + process.set([0xde, 0xad], sourcePtr); + h.storage.set([0xde, 0xad], 150); + h.setLogicalSize(152); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 2, 150], 2, 0, + ); + expect(backing.size).toBe(152); + + h.setLogicalSize(50); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Ftruncate, [4, 50], 0, 0, + ); + expect(backing.size).toBe(50); + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + ); + expect(process[addr + 80]).toBe(0); + + h.setLogicalSize(200); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Ftruncate, [4, 200], 0, 0, + ); + expect(backing.size).toBe(200); + }); + + it("refreshes size after direct and in-kernel copy mutations", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + expect(h.map(pid, 4, 0x1000)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const cases = [ + [ABI_SYSCALLS.Write, [4], 120], + [ABI_SYSCALLS.Writev, [4], 130], + [ABI_SYSCALLS.Pwritev, [4], 140], + [ABI_SYSCALLS.Sendfile, [4, 9], 150], + [SYS_COPY_FILE_RANGE, [4, 0, 9, 0], 160], + [SYS_SPLICE, [4, 0, 9, 0], 170], + ] as const; + for (const [syscallNr, args, size] of cases) { + h.setLogicalSize(size); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), syscallNr, args, 1, 0, + ); + expect(backing.size).toBe(size); + } + }); + + it("publishes mapped input before copy_file_range and splice", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + + for (const [index, syscallNr] of [ + SYS_COPY_FILE_RANGE, + SYS_SPLICE, + ].entries()) { + const offset = 61 + index; + process[addr + offset] = 0xd0 + index; + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), + syscallNr, + [4, 0, 9, 0, 1, 0], + )).toBe(true); + expect(h.storage[offset]).toBe(0xd0 + index); + } + }); + + it("invalidates stale pages after reread failure and recovers at completion", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const mapping = (h.kw as any).sharedMappings.get(pid).get(addr); + h.storage[25] = 0xd5; + h.io.read.mockImplementationOnce(() => { + throw new Error("one-shot reread failure"); + }); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Write, [4], 1, 0, + ); + expect(backing.pages.has(0)).toBe(false); + expect(mapping.seenVersion).toBeLessThan(backing.version); + + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + ); + expect(backing.pages.has(0)).toBe(true); + expect(new Uint8Array(h.memories.get(pid)!.buffer)[addr + 25]).toBe(0xd5); + expect(mapping.seenVersion).toBe(backing.version); + }); + + it("keeps persistently unreadable direct-write cache pages invalid", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + const mapping = (h.kw as any).sharedMappings.get(pid).get(addr); + h.storage[26] = 0xe6; + h.io.read.mockImplementation(() => { + throw new Error("persistent reread failure"); + }); + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Write, [4], 1, 0, + ); + expect(() => (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + )).toThrow(/persistent reread failure/); + expect(backing.pages.has(0)).toBe(false); + expect(mapping.seenVersion).toBeLessThan(backing.version); + }); + + it("completes EIO when recovery follows a persistent refresh failure", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const channel = h.channels.get(pid)!; + expect(h.map(pid, 4, addr)).toBe(true); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).invalidateSharedMmapBackingPages(backing); + h.io.read.mockImplementation(() => { + throw new Error("persistent refresh failure"); + }); + + const kernelHandle = vi.fn(); + const relistenChannel = vi.fn(); + Object.assign(h.kw as any, { + config: {}, + syscallRing: new Map(), + syscallTraceEnabled: false, + kernelMemory: new WebAssembly.Memory({ initial: 2 }), + scratchOffset: 0, + kernelInstance: { exports: { kernel_handle_channel: kernelHandle } }, + clearSocketTimeout: vi.fn(), + clearReadinessWait: vi.fn(), + pendingCancels: new Set(), + relistenChannel, + }); + writeChannelSyscall(channel, ABI_SYSCALLS.Getpid, []); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + (h.kw as any).handleSyscall(channel); + + consoleError.mockRestore(); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(kernelHandle).not.toHaveBeenCalled(); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-5); + expect(view.getUint32(CH_ERRNO, true)).toBe(5); + expect(Atomics.load(channel.i32View, CH_STATUS / 4)) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(relistenChannel).toHaveBeenCalledWith(channel); + }); + + it("keeps asynchronous normal completion live after coherence failure", () => { + const h = createFileHarness(); + const channel = h.channels.get(h.pids[0])!; + const relistenChannel = vi.fn(); + Object.assign(h.kw as any, { + synchronizeSharedMemoryForBoundary: vi.fn(() => { + throw new Error("asynchronous refresh failure"); + }), + clearSocketTimeout: vi.fn(), + clearReadinessWait: vi.fn(), + drainAllPtyOutputs: vi.fn(), + flushTcpSendPipes: vi.fn(), + drainAndProcessWakeupEvents: vi.fn(), + relistenChannel, + }); + const consoleError = vi.spyOn(console, "error").mockImplementation(() => {}); + + (h.kw as any).completeChannel( + channel, + ABI_SYSCALLS.Getpid, + [], + undefined, + channel.pid, + 0, + ); + + consoleError.mockRestore(); + const view = new DataView(channel.memory.buffer, channel.channelOffset); + expect(Number(view.getBigInt64(CH_RETURN, true))).toBe(-5); + expect(view.getUint32(CH_ERRNO, true)).toBe(5); + expect(Atomics.load(channel.i32View, CH_STATUS / 4)) + .toBe(CHANNEL_STATUS_COMPLETE); + expect(relistenChannel).toHaveBeenCalledWith(channel); + }); + + it("uses the full safe pwrite offset and rejects invalid negative offsets", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const sourcePtr = 0x7000; + const offset = 0x1_0000_0010; + h.setLogicalSize(offset + 1); + expect(h.map(pid, 4, addr)).toBe(true); + const process = new Uint8Array(h.memories.get(pid)!.buffer); + process[sourcePtr] = 0x9a; + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 1, offset], 1, 0, + ); + const page = Math.floor(offset / 4096); + expect(backing.pages.get(page)[offset % 4096]).toBe(0x9a); + + const version = backing.version; + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, sourcePtr, 1, -1], 1, 0, + ); + expect(backing.sizeValid).toBe(false); + expect(backing.version).toBeGreaterThan(version); + }); + + it("rejects shared memfd mappings deliberately without affecting private mmap", () => { + const h = createFileHarness(); + (h.kw as any).getFdStatForSharedMapping.mockReturnValue({ + kind: "ok", + value: { + dev: 0n, + ino: 1n, + size: h.logicalSize(), + mode: REGULAR_MODE, + hostHandle: null, + }, + }); + expect(h.mapResult(h.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 95 }); + expect(h.retainHostFileHandle).not.toHaveBeenCalled(); + expect((h.kw as any).sharedMappings.size).toBe(0); + // The _handleSyscallInner preflight is gated on MAP_SHARED; MAP_PRIVATE + // still uses the existing fd-pread population path. + }); + + it("rejects a backend that cannot promise stable file identity", () => { + const h = createFileHarness(); + h.io.fileHandleIdentity.mockReturnValue(null); + expect(h.mapResult(h.pids[0], 4, 0x1000)) + .toEqual({ kind: "error", errno: 95 }); + expect(h.retainHostFileHandle).not.toHaveBeenCalled(); + }); + + it("fails storage syscalls before the kernel when dirty-page flush fails", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + expect(h.map(pid, 4, addr)).toBe(true); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 9] = 0xcc; + h.io.write.mockReturnValueOnce(0); + + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4], + )).toBe(false); + // close does not need the guest fd for writeback: the stable handle owns + // the mapping lifetime and is flushed later by msync/munmap/exit. + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Close, [4], + )).toBe(true); + }); + + it("skips file-coherence hooks when no shared file backing exists", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const syncFile = vi.spyOn(h.kw as any, "syncFileSharedMappingsFromProcess"); + const flushFd = vi.spyOn(h.kw as any, "flushSharedBackingForFd"); + const invalidateFd = vi.spyOn(h.kw as any, "invalidateSharedMmapFdCache"); + + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, 0, 1, 0], + )).toBe(true); + (h.kw as any).handleSharedMappingsAfterFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Open, [0, 0], 4, 0, + ); + + expect(syncFile).not.toHaveBeenCalled(); + expect(flushFd).not.toHaveBeenCalled(); + expect(invalidateFd).not.toHaveBeenCalled(); + }); + + it("reaps a retained zero-reference backing after writeback recovers", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const addr = 0x1000; + const write = h.io.write.getMockImplementation()!; + expect(h.map(pid, 4, addr)).toBe(true); + new Uint8Array(h.memories.get(pid)!.buffer)[addr + 7] = 0xee; + (h.kw as any).syncFileSharedMappingsFromProcess( + (h.kw as any).processes.get(pid), + { force: true }, + ); + h.io.write.mockReturnValue(0); + + (h.kw as any).cleanupSharedMappings(pid, addr, 4096); + const backing = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + expect(backing.refCount).toBe(0); + expect(backing.dirtyPages.has(0)).toBe(true); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + + h.io.write.mockImplementation(write); + expect((h.kw as any).flushSharedMappingsBeforeFileSyscall( + h.channels.get(pid), ABI_SYSCALLS.Pwrite, [4, 0, 1, 0], + )).toBe(true); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect(h.releaseHostFileHandle).toHaveBeenCalledWith(backing.handle); + expect(h.close).not.toHaveBeenCalledWith(backing.handle); + }); + + it("releases mixed anonymous and file mappings through their own backings", () => { + const h = createFileHarness(); + const pid = h.pids[0]; + const fileAddr = 0x1000; + const anonymousAddr = 0x3000; + expect(h.map(pid, 4, fileAddr)).toBe(true); + const fileBacking = Array.from((h.kw as any).sharedMmapBackings.values())[0]; + (h.kw as any).trackAnonymousSharedMapping( + h.channels.get(pid), + anonymousAddr, + [0, 4096, PROT_WRITE, MAP_SHARED | MAP_ANONYMOUS, -1, 0], + ); + expect((h.kw as any).sharedMappings.get(pid).size).toBe(2); + expect((h.kw as any).anonymousSharedBackings.size).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(pid, false); + + expect((h.kw as any).sharedMappings.has(pid)).toBe(false); + expect((h.kw as any).sharedMmapBackings.size).toBe(0); + expect((h.kw as any).anonymousSharedBackings.size).toBe(0); + expect(fileBacking.refCount).toBe(0); + expect(h.releaseHostFileHandle).toHaveBeenCalledWith(fileBacking.handle); + expect(h.close).not.toHaveBeenCalledWith(fileBacking.handle); + }); +}); diff --git a/host/test/fixtures/deep-wasm-recursion-worker.mjs b/host/test/fixtures/deep-wasm-recursion-worker.mjs new file mode 100644 index 0000000000..72ce50e580 --- /dev/null +++ b/host/test/fixtures/deep-wasm-recursion-worker.mjs @@ -0,0 +1,7 @@ +import { readFileSync } from "node:fs"; +import { parentPort, workerData } from "node:worker_threads"; + +const bytes = readFileSync(workerData.wasmPath); +const { instance } = await WebAssembly.instantiate(bytes); +const recurse = instance.exports.recurse; +parentPort.postMessage({ result: recurse(workerData.depth) }); diff --git a/host/test/fixtures/deep-wasm-recursion.wat b/host/test/fixtures/deep-wasm-recursion.wat new file mode 100644 index 0000000000..4be3073ea0 --- /dev/null +++ b/host/test/fixtures/deep-wasm-recursion.wat @@ -0,0 +1,14 @@ +(module + (func $recurse (export "recurse") (param $depth i32) (result i32) + local.get $depth + i32.eqz + if (result i32) + i32.const 0 + else + local.get $depth + i32.const 1 + i32.sub + call $recurse + i32.const 1 + i32.add + end)) diff --git a/host/test/fixtures/sharedfs-lock-release-worker.mjs b/host/test/fixtures/sharedfs-lock-release-worker.mjs new file mode 100644 index 0000000000..f190f235fe --- /dev/null +++ b/host/test/fixtures/sharedfs-lock-release-worker.mjs @@ -0,0 +1,10 @@ +import { parentPort, workerData } from "node:worker_threads"; + +const words = new Int32Array(workerData); +const namespaceLockIndex = 64 / Int32Array.BYTES_PER_ELEMENT; +Atomics.store(words, namespaceLockIndex, 1); +parentPort.postMessage("locked"); +setTimeout(() => { + Atomics.store(words, namespaceLockIndex, 0); + Atomics.notify(words, namespaceLockIndex); +}, 100); diff --git a/host/test/fixtures/sharedfs-namespace-worker.ts b/host/test/fixtures/sharedfs-namespace-worker.ts new file mode 100644 index 0000000000..0eb7fd6234 --- /dev/null +++ b/host/test/fixtures/sharedfs-namespace-worker.ts @@ -0,0 +1,57 @@ +import { parentPort, workerData } from "node:worker_threads"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; + +const { fsBuffer, controlBuffer, role, iterations } = workerData as { + fsBuffer: SharedArrayBuffer; + controlBuffer: SharedArrayBuffer; + role: "mutator" | "observer"; + iterations: number; +}; +const control = new Int32Array(controlBuffer); +const fs = MemoryFileSystem.fromExisting(fsBuffer); +const O_RDONLY = 0; +const O_WRONLY = 1; +const O_CREAT = 0x40; +const O_TRUNC = 0x200; + +while (Atomics.load(control, 0) === 0) Atomics.wait(control, 0, 0); + +try { + if (role === "mutator") { + for (let i = 0; i < iterations; i++) { + try { fs.unlink("/slot"); } catch { /* observer may hold an unlinked fd */ } + + let fd = fs.open("/other", O_WRONLY | O_CREAT | O_TRUNC, 0o644); + fs.write(fd, new Uint8Array([0x22]), null, 1); + fs.close(fd); + fs.unlink("/other"); + + fd = fs.open("/slot", O_WRONLY | O_CREAT | O_TRUNC, 0o644); + fs.write(fd, new Uint8Array([0x11]), null, 1); + fs.close(fd); + } + } else { + for (let i = 0; i < iterations; i++) { + let fd: number | null = null; + try { + fd = fs.open("/slot", O_RDONLY, 0); + const byte = new Uint8Array(1); + const read = fs.read(fd, byte, null, 1); + if (read === 1 && byte[0] !== 0x11) { + throw new Error(`path ABA exposed data from recycled inode: ${byte[0]}`); + } + } catch (error) { + if (error instanceof Error && error.message.includes("path ABA")) throw error; + // ENOENT is valid between unlink and recreate. + } finally { + if (fd !== null) fs.close(fd); + } + } + } + parentPort!.postMessage({ ok: true }); +} catch (error) { + parentPort!.postMessage({ + ok: false, + error: error instanceof Error ? error.stack ?? error.message : String(error), + }); +} diff --git a/host/test/fork-dlopen-replay-e2e.test.ts b/host/test/fork-dlopen-replay-e2e.test.ts index 57f4cf17a0..7a5b0375d8 100644 --- a/host/test/fork-dlopen-replay-e2e.test.ts +++ b/host/test/fork-dlopen-replay-e2e.test.ts @@ -13,8 +13,8 @@ * This fixture is expected to FAIL until that fix lands. */ import { describe, it, expect, beforeAll } from "vitest"; -import { execSync } from "node:child_process"; -import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs"; +import { execFileSync, execSync } from "node:child_process"; +import { readFileSync, writeFileSync, mkdirSync, existsSync, realpathSync } from "node:fs"; import { join, dirname } from "node:path"; import { tmpdir } from "node:os"; import { fileURLToPath } from "node:url"; @@ -23,13 +23,25 @@ import { NodePlatformIO } from "../src/platform/node"; const __dirname = dirname(fileURLToPath(import.meta.url)); const REPO_ROOT = join(__dirname, "../.."); -const SYSROOT = join(REPO_ROOT, "sysroot"); +const SYSROOT = process.env.KANDELO_TEST_SYSROOT ?? join(REPO_ROOT, "sysroot"); const GLUE_DIR = join(REPO_ROOT, "libc", "glue"); -const LLVM_BIN = process.env.LLVM_BIN || "/opt/homebrew/opt/llvm@21/bin"; -const CLANG = `${LLVM_BIN}/clang`; -const WASM_LD = process.env.LLVM_BIN - ? `${LLVM_BIN}/wasm-ld` - : "/opt/homebrew/bin/wasm-ld"; +const clangDriver = process.env.CLANG ?? "clang"; + +function llvmTool(name: "clang" | "clang++" | "wasm-ld"): string { + const override = name === "wasm-ld" ? process.env.WASM_LD : undefined; + if (override) return override; + try { + return execFileSync(clangDriver, [`-print-prog-name=${name}`], { + encoding: "utf8", + }).trim() || name; + } catch { + return name; + } +} + +const CLANG = llvmTool("clang"); +const CLANGXX = llvmTool("clang++"); +const WASM_LD = llvmTool("wasm-ld"); const FORK_INSTRUMENT = join(REPO_ROOT, "scripts", "run-wasm-fork-instrument.sh"); const hasSysroot = existsSync(join(SYSROOT, "lib", "libc.a")); @@ -38,6 +50,39 @@ const hasKernel = existsSync(join(REPO_ROOT, "binaries", "kernel.wasm")) || const BUILD_DIR = join(tmpdir(), "wasm-fork-dlopen-replay-e2e"); +function findLibcxxPrefix(): string | undefined { + const explicit = process.env.KANDELO_LIBCXX_PREFIX; + if ( + explicit + && existsSync(join(explicit, "lib", "libc++-pic.a")) + && existsSync(join(explicit, "lib", "libc++abi-pic.a")) + ) { + return explicit; + } + const sysrootArchive = join(SYSROOT, "lib", "libc++.a"); + if (!existsSync(sysrootArchive)) return undefined; + const prefix = dirname(dirname(realpathSync(sysrootArchive))); + return existsSync(join(prefix, "lib", "libc++-pic.a")) + && existsSync(join(prefix, "lib", "libc++abi-pic.a")) + ? prefix + : undefined; +} + +const libcxxPrefix = findLibcxxPrefix(); +const hasCppPrerequisites = hasSysroot && hasKernel && libcxxPrefix !== undefined; + +if (process.env.KANDELO_REQUIRE_CPP_DYLINK_FORK_E2E === "1" && !hasCppPrerequisites) { + throw new Error( + "C++ dlopen/fork e2e was required but kernel.wasm, sysroot/libc.a, or libcxx PIC archives are missing", + ); +} + +const CPP_RUNTIME_MAIN_EXPORTS = [ + "getenv", "fprintf", "fflush", "malloc", "strlen", "memcmp", "realloc", + "free", "fwrite", "vfprintf", "fputc", "abort", "memchr", "snprintf", + "aligned_alloc", "strcmp", "pthread_mutex_lock", "pthread_mutex_unlock", "calloc", +]; + /** Build a shared Wasm library (.so side module) from C source. */ function buildSharedLib(source: string, name: string): string { const srcPath = join(BUILD_DIR, `${name}.c`); @@ -58,8 +103,46 @@ function buildSharedLib(source: string, name: string): string { return soPath; } +/** Build a real C++ EH side module, including its TLS-bearing unwinder. */ +function buildCppSharedLib(source: string, name: string): string { + if (!libcxxPrefix) throw new Error("libcxx PIC prefix unavailable"); + const srcPath = join(BUILD_DIR, `${name}.cpp`); + const objPath = join(BUILD_DIR, `${name}.o`); + const soPath = join(BUILD_DIR, `${name}.so`); + writeFileSync(srcPath, source); + execFileSync(CLANGXX, [ + "--target=wasm32-unknown-unknown", + `--sysroot=${SYSROOT}`, + "-nostdlib", + "-fPIC", + "-O2", + "-fwasm-exceptions", + "-matomics", + "-mbulk-memory", + `-I${join(libcxxPrefix, "include", "c++", "v1")}`, + "-c", + srcPath, + "-o", + objPath, + ], { stdio: "pipe" }); + execFileSync(WASM_LD, [ + "--experimental-pic", + "--shared", + "--shared-memory", + "--export-all", + "--allow-undefined", + "--export=__tls_base", + "-o", + soPath, + objPath, + join(libcxxPrefix, "lib", "libc++-pic.a"), + join(libcxxPrefix, "lib", "libc++abi-pic.a"), + ], { stdio: "pipe" }); + return soPath; +} + /** Build a main program with dlopen + fork support. */ -function buildMainProgram(source: string, name: string): string { +function buildMainProgram(source: string, name: string, forceExports: string[] = []): string { const srcPath = join(BUILD_DIR, `${name}.c`); const wasmPath = join(BUILD_DIR, `${name}.wasm`); @@ -97,6 +180,8 @@ function buildMainProgram(source: string, name: string): string { "-Wl,--export=__tls_align", "-Wl,--export=__stack_pointer", "-Wl,--export=__wasm_thread_init", + ...(forceExports.length > 0 ? ["-Wl,--export-all"] : []), + ...forceExports.map((symbol) => `-Wl,-u,${symbol}`), ]; const allArgs = [...cflags, srcPath, ...linkFlags, "-o", wasmPath]; @@ -188,4 +273,134 @@ describe.skipIf(!hasSysroot || !hasKernel)("fork after dlopen end-to-end", () => expect(result.exitCode).toBe(0); expect(result.stdout).toContain("ok"); }); + + it("fails pthread dlopen and fork after process dlopen without creating a child", { timeout: 30_000 }, async () => { + const soPath = buildSharedLib( + `int pthread_boundary_fixture(void) { return 1; }`, + "libpthreadboundary", + ); + const wasmPath = buildMainProgram(` + #include + #include + #include + #include + #include + #include + + static const char *side_path; + static int thread_result; + + static void *run_thread(void *unused) { + (void)unused; + void *nested = dlopen(side_path, RTLD_NOW); + const char *error = dlerror(); + if (nested != NULL || error == NULL || strstr(error, "pthread workers") == NULL) { + thread_result = 1; + return NULL; + } + errno = 0; + pid_t child = fork(); + if (child != -1 || errno != ENOTSUP) { + thread_result = 2; + return NULL; + } + thread_result = 0; + return NULL; + } + + int main(int argc, char **argv) { + side_path = argv[1]; + void *side = dlopen(side_path, RTLD_NOW); + if (!side) { fprintf(stderr, "main dlopen: %s\\n", dlerror()); return 2; } + pthread_t thread; + if (pthread_create(&thread, NULL, run_thread, NULL) != 0) return 3; + if (pthread_join(thread, NULL) != 0) return 4; + if (thread_result != 0) return 10 + thread_result; + puts("pthread dylink boundary ok"); + return 0; + } + `, "test-pthread-dylink-boundary"); + + const result = await runCentralizedProgram({ + programPath: wasmPath, + argv: ["pthread-dylink-boundary", soPath], + timeout: 30_000, + io: io(), + captureForkCount: true, + }); + + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("pthread dylink boundary ok"); + expect(result.forkCount).toBe(0n); + }); + + it.skipIf(!hasCppPrerequisites)( + "child preserves side-module TLS for a real compiled C++ throw/catch", + { timeout: 30_000 }, + async () => { + const soPath = buildCppSharedLib(` + thread_local int cpp_tls_marker = 7; + extern "C" void cpp_set_tls_marker(int value) { cpp_tls_marker = value; } + extern "C" int cpp_get_tls_marker(void) { return cpp_tls_marker; } + extern "C" int cpp_throw_and_catch(int value) { + try { throw value; } + catch (int caught) { return caught; } + } + `, "libcppthrow"); + const sideModule = new WebAssembly.Module( + new Uint8Array(readFileSync(soPath)) as unknown as BufferSource, + ); + const sideImports = WebAssembly.Module.imports(sideModule); + const sideExports = WebAssembly.Module.exports(sideModule); + expect(sideImports.some((entry) => + entry.module === "env" + && entry.name === "__cpp_exception" + && (entry.kind as string) === "tag" + )).toBe(true); + expect(sideExports.map((entry) => entry.name)).toEqual( + expect.arrayContaining(["__tls_base", "__tls_size", "__wasm_init_tls"]), + ); + + const wasmPath = buildMainProgram(` + #include + #include + #include + #include + #include + typedef int (*cpp_throw_fn)(int); + typedef void (*cpp_set_marker_fn)(int); + typedef int (*cpp_get_marker_fn)(void); + int main(int argc, char **argv) { + void *lib = dlopen(argv[1], RTLD_NOW); + if (!lib) { fprintf(stderr, "dlopen: %s\\n", dlerror()); return 2; } + cpp_throw_fn run = (cpp_throw_fn)dlsym(lib, "cpp_throw_and_catch"); + cpp_set_marker_fn set_marker = (cpp_set_marker_fn)dlsym(lib, "cpp_set_tls_marker"); + cpp_get_marker_fn get_marker = (cpp_get_marker_fn)dlsym(lib, "cpp_get_tls_marker"); + if (!run || !set_marker || !get_marker || run(41) != 41) return 3; + set_marker(99); + if (get_marker() != 99) return 4; + pid_t pid = fork(); + if (pid == 0) _exit(get_marker() == 99 && run(42) == 42 ? 0 : 5); + if (pid < 0) return 6; + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 7; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 8; + puts("cpp throw after fork ok"); + return 0; + } + `, "test-cpp-throw-after-dlopen-fork", CPP_RUNTIME_MAIN_EXPORTS); + + const result = await runCentralizedProgram({ + programPath: wasmPath, + argv: ["cpp-throw-main", soPath], + timeout: 30_000, + io: io(), + }); + + expect(result.stderr).toBe(""); + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("cpp throw after fork ok"); + }, + ); }); diff --git a/host/test/fork-from-dlopen-side-module-e2e.test.ts b/host/test/fork-from-dlopen-side-module-e2e.test.ts new file mode 100644 index 0000000000..aae67a2daa --- /dev/null +++ b/host/test/fork-from-dlopen-side-module-e2e.test.ts @@ -0,0 +1,194 @@ +import { beforeAll, describe, expect, it } from "vitest"; +import { execFileSync } from "node:child_process"; +import { + existsSync, + mkdirSync, + readFileSync, + renameSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { NodePlatformIO } from "../src/platform/node"; +import { + FORK_CAP_DYLINK_MAIN, + FORK_CAP_SIDE_ENTRY, + readForkInstrumentCapabilities, +} from "../src/dylink"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(__dirname, "../.."); +const sysroot = join(repoRoot, "sysroot"); +const glueDir = join(repoRoot, "libc", "glue"); +const clangDriver = process.env.CLANG ?? "clang"; +const instrument = join(repoRoot, "scripts", "run-wasm-fork-instrument.sh"); +const buildDir = join(tmpdir(), "kandelo-fork-from-side-module"); +const hasPrerequisites = + existsSync(join(sysroot, "lib", "libc.a")) + && ( + existsSync(join(repoRoot, "binaries", "kernel.wasm")) + || existsSync(join(repoRoot, "local-binaries", "kernel.wasm")) + ); + +if (process.env.KANDELO_REQUIRE_SIDE_MODULE_FORK_E2E === "1" && !hasPrerequisites) { + throw new Error( + "side-module fork e2e was required but sysroot/libc.a or kernel.wasm is missing", + ); +} + +function llvmTool(name: "clang" | "wasm-ld"): string { + if (name === "wasm-ld" && process.env.WASM_LD) return process.env.WASM_LD; + // Nix's native clang wrapper injects Darwin hardening flags that are invalid + // for wasm32. Ask the driver for its underlying LLVM tools so this fixture + // follows the same cross-target path as the repository build scripts. Keep + // discovery lazy so a deliberately skipped fixture needs no compiler. + return execFileSync(clangDriver, [`-print-prog-name=${name}`], { + encoding: "utf8", + }).trim() || name; +} + +function instrumentInPlace(wasmPath: string, entry?: string): void { + const output = `${wasmPath}.instrumented`; + const args = [wasmPath, "-o", output]; + if (entry) args.push("--entry", entry); + execFileSync(instrument, args, { stdio: "pipe" }); + renameSync(output, wasmPath); +} + +function buildSharedLibrary(source: string): string { + const sourcePath = join(buildDir, "libforkinside.c"); + const objectPath = join(buildDir, "libforkinside.o"); + const libraryPath = join(buildDir, "libforkinside.so"); + writeFileSync(sourcePath, source); + execFileSync(llvmTool("clang"), [ + "--target=wasm32-unknown-unknown", + "-fPIC", + "-O2", + "-matomics", + "-mbulk-memory", + "-c", + sourcePath, + "-o", + objectPath, + ], { stdio: "pipe" }); + execFileSync(llvmTool("wasm-ld"), [ + "--experimental-pic", + "--shared", + "--shared-memory", + "--export-all", + "--allow-undefined", + "-o", + libraryPath, + objectPath, + ], { stdio: "pipe" }); + instrumentInPlace(libraryPath, "env.fork"); + return libraryPath; +} + +function buildMainProgram(source: string): string { + const sourcePath = join(buildDir, "fork-from-side-main.c"); + const wasmPath = join(buildDir, "fork-from-side-main.wasm"); + writeFileSync(sourcePath, source); + execFileSync(llvmTool("clang"), [ + "--target=wasm32-unknown-unknown", + `--sysroot=${sysroot}`, + "-nostdlib", + "-O2", + "-matomics", + "-mbulk-memory", + "-fno-trapping-math", + sourcePath, + join(glueDir, "channel_syscall.c"), + join(glueDir, "compiler_rt.c"), + join(glueDir, "dlopen.c"), + join(sysroot, "lib", "crt1.o"), + join(sysroot, "lib", "libc.a"), + "-Wl,--entry=_start", + "-Wl,--export=_start", + "-Wl,--export=__heap_base", + "-Wl,--import-memory", + "-Wl,--shared-memory", + "-Wl,--max-memory=1073741824", + "-Wl,--allow-undefined", + "-Wl,--global-base=1114112", + "-Wl,--table-base=3", + "-Wl,--export-table", + "-Wl,--growable-table", + "-Wl,--export=__wasm_init_tls", + "-Wl,--export=__tls_base", + "-Wl,--export=__tls_size", + "-Wl,--export=__tls_align", + "-Wl,--export=__stack_pointer", + "-Wl,--export=__wasm_thread_init", + "-Wl,--export-all", + "-o", + wasmPath, + ], { stdio: "pipe" }); + instrumentInPlace(wasmPath); + return wasmPath; +} + +describe.skipIf(!hasPrerequisites)("fork from a dlopened side module", () => { + beforeAll(() => mkdirSync(buildDir, { recursive: true })); + + it("preserves the side frame and returns in both parent and child", async () => { + const libraryPath = buildSharedLibrary(` + extern int fork(void); + extern void exit(int); + int side_fork(void) { + volatile int preserved = 37; + int pid = fork(); + if (preserved != 37) exit(91); + if (pid == 0) exit(0); + return pid; + } + `); + const programPath = buildMainProgram(` + #include + #include + #include + typedef int (*side_fork_fn)(void); + int main(int argc, char **argv) { + void *lib = dlopen(argv[1], RTLD_NOW); + if (!lib) return 2; + side_fork_fn side_fork = (side_fork_fn)dlsym(lib, "side_fork"); + if (!side_fork) return 3; + for (int i = 0; i < 2; i++) { + int pid = side_fork(); + if (pid < 0) return 4; + int status = 0; + if (waitpid(pid, &status, 0) != pid) return 5; + if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) return 6; + } + puts("side fork ok"); + return 0; + } + `); + + // Ensure the compiler actually produced a side module with fork state; + // a stale/inert fixture must never turn this into a false-positive run. + const libraryModule = new WebAssembly.Module( + new Uint8Array(readFileSync(libraryPath)) as unknown as BufferSource, + ); + expect(WebAssembly.Module.exports(libraryModule).map((entry) => entry.name)) + .toContain("wpk_fork_state"); + expect(readForkInstrumentCapabilities(libraryModule) & FORK_CAP_SIDE_ENTRY) + .toBe(FORK_CAP_SIDE_ENTRY); + const programModule = new WebAssembly.Module( + new Uint8Array(readFileSync(programPath)) as unknown as BufferSource, + ); + expect(readForkInstrumentCapabilities(programModule) & FORK_CAP_DYLINK_MAIN) + .toBe(FORK_CAP_DYLINK_MAIN); + + const result = await runCentralizedProgram({ + programPath, + argv: ["fork-from-side-main", libraryPath], + timeout: 30_000, + io: new NodePlatformIO(), + }); + expect(result.exitCode, `stderr:\n${result.stderr}`).toBe(0); + expect(result.stdout).toContain("side fork ok"); + }, 30_000); +}); diff --git a/host/test/fork-from-thread.test.ts b/host/test/fork-from-thread.test.ts index 2b3a3bb124..63ee648f48 100644 --- a/host/test/fork-from-thread.test.ts +++ b/host/test/fork-from-thread.test.ts @@ -26,33 +26,37 @@ const concurrentForkBinary = tryResolveBinary( const hasConcurrentFork = !!concurrentForkBinary; describe("fork-from-non-main-thread", () => { - it.skipIf(!hasFork)("a pthread fork child can fork again from its inherited continuation", async () => { - const result = await runCentralizedProgram({ - programPath: forkFromThreadBinary!, - argv: ["fork-from-thread"], - timeout: 15_000, - }); + it.skipIf(!hasFork)( + "a pthread fork child can fork again from its inherited continuation", + async () => { + const result = await runCentralizedProgram({ + programPath: forkFromThreadBinary!, + argv: ["fork-from-thread"], + timeout: 15_000, + }); - expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); + expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); - // Parent thread reached the fork-callable code path. - expect(result.stdout).toContain("THREAD_STARTED"); - expect(result.stdout).toContain("PRE_FORK_THREAD"); + // Parent thread reached the fork-callable code path. + expect(result.stdout).toContain("THREAD_STARTED"); + expect(result.stdout).toContain("PRE_FORK_THREAD"); - // Parent thread received a positive child pid. - expect(result.stdout).toMatch(/PARENT_THREAD: child=\d+/); + // Parent thread received a positive child pid. + expect(result.stdout).toMatch(/PARENT_THREAD: child=\d+/); - // Child resumed inside the thread function, took the pid==0 branch, - // ran the post-fork code, and exited cleanly. This is the load-bearing - // expectation: without correct fork-from-thread, the child traps in - // _start before any thread code runs. - expect(result.stdout).toContain("GRANDCHILD_THREAD: ok"); - expect(result.stdout).toMatch(/CHILD_THREAD: grandchild=\d+/); + // Child resumed inside the thread function, took the pid==0 branch, + // ran the post-fork code, and exited cleanly. This is the load-bearing + // expectation: without correct fork-from-thread, the child traps in + // _start before any thread code runs. + expect(result.stdout).toContain("GRANDCHILD_THREAD: ok"); + expect(result.stdout).toMatch(/CHILD_THREAD: grandchild=\d+/); - // Final PASS line — main thread joined the worker and waitpid()'d - // the child to a normal exit. - expect(result.stdout).toContain("PASS"); - }); + // Final PASS line — main thread joined the worker and waitpid()'d + // the child to a normal exit. + expect(result.stdout).toContain("PASS"); + }, + 20_000, + ); it.skipIf(!hasConcurrentFork)( "concurrent pthread forks keep their continuation frames isolated", @@ -66,5 +70,6 @@ describe("fork-from-non-main-thread", () => { expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); expect(result.stdout).toContain("PASS: 16 concurrent fork pairs"); }, + 70_000, ); }); diff --git a/host/test/fork-save-buffer-overrun.test.ts b/host/test/fork-save-buffer-overrun.test.ts new file mode 100644 index 0000000000..9387aeb8b5 --- /dev/null +++ b/host/test/fork-save-buffer-overrun.test.ts @@ -0,0 +1,160 @@ +/** + * Unit tests for `forkSaveBufferOverrun` — the host-side detector that turns a + * fork-continuation save-buffer overrun into a truthful failure instead of + * silent syscall-channel corruption. + * + * The instrumented unwind keeps `current_pos` (a pointer-width integer at the + * base of the save buffer) as the absolute high-water linear-memory address it + * wrote to. Main-process and pthread buffers abut their syscall channels; + * fork-capable side modules use independent allocations of the same explicit + * size. In either case, `current_pos > forkBufAddr + forkBufSize` means the + * unwind crossed the reserved continuation boundary. See worker-main.ts and + * crates/fork-instrument/src/runtime.rs. + * + * End-to-end behavior was validated against the real ABI 18 Homebrew launcher, + * whose Bash fork needs 20,012 bytes and now fails with the exact diagnostic + * instead of corrupting its channel. These tests pin the detection arithmetic + * that the fork paths rely on. + */ +import { describe, it, expect } from "vitest"; +import type { SideModuleForkState } from "../src/dylink"; +import { + finalizeSideModuleForkUnwind, + forkSaveBufferOverrun, +} from "../src/worker-main"; +import { FORK_SAVE_BUFFER_SIZE } from "../src/process-memory"; + +const FORK_BUF_ADDR = 65536; // arbitrary page-aligned buffer base for the test +const SIDE_FORK_BUF_ADDR = 32768; // separate from the process-main test buffer + +function writeCurrentPos( + memory: WebAssembly.Memory, + addr: number, + value: number, + ptrWidth: 4 | 8, +): void { + const view = new DataView(memory.buffer); + if (ptrWidth === 8) view.setBigUint64(addr, BigInt(value), true); + else view.setUint32(addr, value, true); +} + +function createSideForkState( + name: string, + forkBufAddr: number, +): { state: SideModuleForkState; runtimeState: () => number } { + let value = 1; // UNWINDING + const instance = { + exports: { + wpk_fork_state: () => value, + wpk_fork_unwind_end: () => { + value = 0; // NORMAL + }, + }, + } as unknown as WebAssembly.Instance; + return { + state: { + name, + instance, + forkBufAddr, + forkBufSize: FORK_SAVE_BUFFER_SIZE, + }, + runtimeState: () => value, + }; +} + +describe("forkSaveBufferOverrun", () => { + it("reports no overrun when the save fits within the buffer", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos(memory, FORK_BUF_ADDR, FORK_BUF_ADDR + 200, 4); + expect( + forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4, FORK_SAVE_BUFFER_SIZE), + ).toBe(0); + }); + + it("treats current_pos exactly at the buffer size as fitting (no overrun)", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE, + 4, + ); + expect( + forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4, FORK_SAVE_BUFFER_SIZE), + ).toBe(0); + }); + + it("reports the exact overrun in bytes when the buffer is exceeded", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE + 4096, + 4, + ); + expect( + forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4, FORK_SAVE_BUFFER_SIZE), + ).toBe(4096); + }); + + it("reports the exact ABI 18 Homebrew launcher overrun", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const observedFrameBytes = 20_012; + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + observedFrameBytes, + 4, + ); + expect( + forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 4, FORK_SAVE_BUFFER_SIZE), + ).toBe(observedFrameBytes - FORK_SAVE_BUFFER_SIZE); + }); + + it("reads current_pos as i64 on the wasm64 path", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + writeCurrentPos( + memory, + FORK_BUF_ADDR, + FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE + 1, + 8, + ); + expect( + forkSaveBufferOverrun(memory, FORK_BUF_ADDR, 8, FORK_SAVE_BUFFER_SIZE), + ).toBe(1); + }); + + it("accepts an independently allocated side-module save that fits", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const side = createSideForkState("libintl.so", SIDE_FORK_BUF_ADDR); + writeCurrentPos( + memory, + SIDE_FORK_BUF_ADDR, + SIDE_FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE, + 4, + ); + + expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)) + .not.toThrow(); + expect(side.runtimeState()).toBe(0); + }); + + it("rejects an overflowing side-module save before fork dispatch", () => { + const memory = new WebAssembly.Memory({ initial: 3 }); + const side = createSideForkState("libintl.so", SIDE_FORK_BUF_ADDR); + const overrun = 73; + writeCurrentPos( + memory, + SIDE_FORK_BUF_ADDR, + SIDE_FORK_BUF_ADDR + FORK_SAVE_BUFFER_SIZE + overrun, + 4, + ); + + expect(() => finalizeSideModuleForkUnwind(memory, side.state, 4)).toThrow( + `libintl.so: side-module fork() continuation save buffer overflow — ` + + `the call stack at fork() needed ${FORK_SAVE_BUFFER_SIZE + overrun} ` + + `bytes but only ${FORK_SAVE_BUFFER_SIZE}`, + ); + expect(side.runtimeState()).toBe(0); + }); +}); diff --git a/host/test/getpwent.test.ts b/host/test/getpwent.test.ts index e7508b06ea..d1ee129dd3 100644 --- a/host/test/getpwent.test.ts +++ b/host/test/getpwent.test.ts @@ -1,9 +1,9 @@ /** * Task 4.6 — getpwent + friends round-trip via the rootfs.vfs mount. * - * After Task 4.5 removed `synthetic_file_content`, the only path for a - * user program to read /etc/passwd, /etc/group, /etc/services et al. is - * through the rootfs image mounted at / by the default Node host setup. + * After Task 4.5 removed static `/etc` interception from the kernel, the only + * path for a user program to read /etc/passwd, /etc/group, /etc/services et + * al. is through the rootfs image mounted at / by the default Node host setup. * * This test runs `examples/getpwent_smoke.wasm` (which calls the libc * NSS-style readers — getpwent/getpwnam/getpwuid/getgrent/getservbyname) @@ -91,7 +91,7 @@ describe.skipIf(!haveSmoke || !haveRootfs)("getpwent via rootfs.vfs mount", () = expect(result.stdout).toContain("GRENT count=8"); }); - it("resolves service names from /etc/services via getservbyname", async () => { + it("resolves canonical service names and aliases from the rootfs image", async () => { const result = await runCentralizedProgram({ programPath: smokeWasm, argv: ["getpwent_smoke"], @@ -101,5 +101,12 @@ describe.skipIf(!haveSmoke || !haveRootfs)("getpwent via rootfs.vfs mount", () = expect(result.exitCode, result.stderr || result.stdout).toBe(0); expect(result.stdout).toContain("SERV name=ssh proto=tcp port=22"); expect(result.stdout).toContain("SERV name=http proto=tcp port=80"); + expect(result.stdout).toContain("SERVENT query=www name=www proto=tcp port=80"); + expect(result.stdout).toContain("SERVENT query=www-http name=www-http proto=tcp port=80"); + expect(result.stdout).toContain("SERVENT query=https name=https proto=tcp port=443"); + expect(result.stdout).toContain("SERVENT query=mysql name=mysql proto=tcp port=3306"); + expect(result.stdout).toContain( + "SERVENT query=postgresql name=postgresql proto=tcp port=5432", + ); }); }); diff --git a/host/test/global-setup.ts b/host/test/global-setup.ts index b46f1d1c62..300adea434 100644 --- a/host/test/global-setup.ts +++ b/host/test/global-setup.ts @@ -9,7 +9,7 @@ */ import { execFileSync } from "node:child_process"; -import { statSync, existsSync } from "node:fs"; +import { statSync, existsSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { chromium } from "@playwright/test"; @@ -21,6 +21,18 @@ 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", + "environment_lifecycle_test.c", + "chown_sentinel_test.c", + "pthread_channel_reuse_test.c", + "wait_lifecycle_test.c", + "pathconf_test.c", + "rlimit_fsize_test.c", + "socket_timeout_options_test.c", + "unix_listener_exec_test.c", "putenv_test.c", "getaddrinfo_test.c", "sysv_ipc_test.c", @@ -42,8 +54,28 @@ const TEST_PROGRAMS = [ "thread-exit-group.c", ]; -/** WAT fixtures used by host/test/wasi-shim.test.ts. */ -const WAT_FIXTURES = ["wasi-args.wat", "wasi-hello.wat"]; +const FORK_INSTRUMENTED_PROGRAMS = new Set([ + "environment_lifecycle_test.c", + "pthread_channel_reuse_test.c", + "unix_listener_exec_test.c", + "wait_lifecycle_test.c", +]); + +/** Operation-boundary regressions that must also run through a memory64 guest. */ +const WASM64_TEST_PROGRAMS = [ + "chown_sentinel_test.c", + "pathconf_test.c", + "rlimit_fsize_test.c", + "socket_timeout_options_test.c", + "wait_lifecycle_test.c", +]; + +/** WAT fixtures used by host runtime tests. */ +const WAT_FIXTURES = [ + "deep-wasm-recursion.wat", + "wasi-args.wat", + "wasi-hello.wat", +]; function needsRebuild(srcFile: string, outFile: string): boolean { if (!existsSync(outFile)) return true; @@ -65,7 +97,41 @@ export async function setup() { if (!needsRebuild(src, out)) continue; console.log(`[global-setup] Compiling ${cFile}...`); - execFileSync("wasm32posix-cc", [src, "-o", out], { + if (FORK_INSTRUMENTED_PROGRAMS.has(cFile)) { + const linked = `${out}.linked`; + try { + execFileSync("wasm32posix-cc", [src, "-o", linked], { + cwd: repoRoot, + stdio: "pipe", + }); + execFileSync( + "bash", + [ + join(repoRoot, "scripts/run-wasm-fork-instrument.sh"), + linked, + "-o", + out, + ], + { cwd: repoRoot, stdio: "pipe" }, + ); + } finally { + rmSync(linked, { force: true }); + } + } else { + execFileSync("wasm32posix-cc", [src, "-o", out], { + cwd: repoRoot, + stdio: "pipe", + }); + } + } + + for (const cFile of WASM64_TEST_PROGRAMS) { + const src = join(examplesDir, cFile); + const out = src.replace(/\.c$/, ".wasm64.wasm"); + if (!needsRebuild(src, out)) continue; + + console.log(`[global-setup] Compiling ${cFile} for wasm64...`); + execFileSync("wasm64posix-cc", [src, "-o", out], { cwd: repoRoot, stdio: "pipe", }); diff --git a/host/test/host-diagnostic-routing.test.ts b/host/test/host-diagnostic-routing.test.ts new file mode 100644 index 0000000000..ae7662aefd --- /dev/null +++ b/host/test/host-diagnostic-routing.test.ts @@ -0,0 +1,34 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +const entries = [ + ["Node", join(repoRoot, "host/src/node-kernel-worker-entry.ts")], + ["browser", join(repoRoot, "host/src/browser-kernel-worker-entry.ts")], +] as const; + +describe.each(entries)("%s kernel-worker diagnostic routing", (_name, path) => { + const source = readFileSync(path, "utf8"); + + it("reserves stderr protocol messages for the kernel's real onStderr bytes", () => { + const stderrPosts = source.match(/type:\s*"stderr"/g) ?? []; + expect(stderrPosts).toHaveLength(1); + expect(source).toMatch(/onStderr:[\s\S]{0,160}type:\s*"stderr"/); + }); + + it("routes lifecycle, protocol, exec, clone, and thread failures as host diagnostics", () => { + for (const diagnosticSource of [ + "worker protocol", + "worker-main error message", + "exec post-commit transition", + "clone allocation", + "thread worker failure", + ]) { + expect(source).toContain(`source: "${diagnosticSource}"`); + } + expect(source).toContain("reportHostDiagnostic({"); + }); +}); diff --git a/host/test/ifhwaddr.test.ts b/host/test/ifhwaddr.test.ts index 08c4eae00d..e71e0c3cb4 100644 --- a/host/test/ifhwaddr.test.ts +++ b/host/test/ifhwaddr.test.ts @@ -1,31 +1,61 @@ import { describe, it, expect } from "vitest"; import { runCentralizedProgram } from "./centralized-test-helper"; import { resolveBinary } from "../src/binary-resolver"; +import { LocalVirtualNetwork } from "../src/networking/virtual-network"; +import { NodePlatformIO } from "../src/platform/node"; -describe("SIOCGIFCONF / SIOCGIFHWADDR", () => { - it("returns a virtual MAC address via ioctl", async () => { - const result = await runCentralizedProgram({ - programPath: resolveBinary("programs/ifhwaddr.wasm"), - timeout: 10_000, - }); +describe("virtual network interface ioctls", () => { + it.each([ + { + arch: "wasm32", + programPath: "programs/ifhwaddr.wasm", + ifreqSize: 32, + }, + { + arch: "wasm64", + programPath: "programs/wasm64/ifhwaddr.wasm", + ifreqSize: 40, + }, + ])( + "honors interface and guest-memory contracts for $arch", + async ({ arch, programPath, ifreqSize }) => { + const network = new LocalVirtualNetwork(); + const io = new NodePlatformIO(); + io.network = network.attachMachine({ + id: `ifhwaddr-${arch}`, + address: [10, 23, 45, 67], + }); - expect(result.exitCode).toBe(0); + const result = await runCentralizedProgram({ + programPath: resolveBinary(programPath), + io, + timeout: 30_000, + }); - // Should find one interface named "eth0" - expect(result.stdout).toContain("interfaces: 1"); - expect(result.stdout).toContain("name: eth0"); + expect(result.exitCode).toBe(0); + expect(result.stdout).not.toContain("FAIL:"); + expect(result.stdout).toContain(`ifreq-size: ${ifreqSize}`); + expect(result.stdout).toContain("ifconf: lo=127.0.0.1 eth0=10.23.45.67"); + expect(result.stdout).toContain("eth0-address: 10.23.45.67"); + expect(result.stdout).toContain( + "libc-name-to-index: lo=1 eth0=2 missing=0 errno=19", + ); + expect(result.stdout).toContain("libc-invalid-index: errno=6"); + expect(result.stdout).toContain("nameindex: lo=1"); + expect(result.stdout).toContain("nameindex: eth0=2"); + expect(result.stdout).toContain( + "PASS: virtual interface ioctl and libc contracts", + ); - // MAC should be locally-administered and non-zero - expect(result.stdout).toContain("locally-administered: yes"); - expect(result.stdout).toContain("non-zero: yes"); + const macMatch = result.stdout.match( + /eth0-mac: ([0-9a-f]{2}(?::[0-9a-f]{2}){5})/, + ); + expect(macMatch).not.toBeNull(); - // MAC format: xx:xx:xx:xx:xx:xx - const macMatch = result.stdout.match(/mac: ([0-9a-f]{2}(?::[0-9a-f]{2}){5})/); - expect(macMatch).not.toBeNull(); - - // Verify locally-administered bit (second-lowest bit of first octet) - const firstOctet = parseInt(macMatch![1].split(":")[0], 16); - expect(firstOctet & 0x02).toBe(0x02); // locally administered - expect(firstOctet & 0x01).toBe(0x00); // unicast - }); + const firstOctet = parseInt(macMatch![1].split(":")[0], 16); + expect(firstOctet & 0x02).toBe(0x02); + expect(firstOctet & 0x01).toBe(0x00); + }, + 30_000, + ); }); diff --git a/host/test/kernel-errno.test.ts b/host/test/kernel-errno.test.ts new file mode 100644 index 0000000000..2b7175d621 --- /dev/null +++ b/host/test/kernel-errno.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { negErrno } from "../src/kernel"; + +describe("negErrno", () => { + it.each([ + ["EBADF", -9], + ["EMFILE", -24], + ["ENFILE", -23], + ["EROFS", -30], + ["ENOTSUP", -95], + ["EOPNOTSUPP", -95], + ])("maps a plain %s platform error", (name, expected) => { + expect(negErrno(new Error(`${name}: backend failure`))).toBe(expected); + }); + + it("preserves numeric backend and Node errno values", () => { + expect(negErrno({ code: -28 })).toBe(-28); + expect(negErrno({ code: 28 })).toBe(-28); + expect(negErrno({ errno: -2 })).toBe(-2); + }); + + it("uses EIO only for an unclassified error", () => { + expect(negErrno(new Error("opaque backend failure"))).toBe(-5); + }); +}); diff --git a/host/test/kernel-owned-boot.test.ts b/host/test/kernel-owned-boot.test.ts new file mode 100644 index 0000000000..0747c6cb26 --- /dev/null +++ b/host/test/kernel-owned-boot.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { + createEmptyBuildFs, + overlayEtcFromRootfs, +} from "../../apps/browser-demos/lib/kernel-owned-boot"; +import type { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +function addFile( + fs: MemoryFileSystem, + path: string, + contents: string, + mode = 0o644, + uid = 0, + gid = 0, +): void { + fs.createFileWithOwner(path, mode, uid, gid, encoder.encode(contents)); +} + +function readFile(fs: MemoryFileSystem, path: string): string { + const stat = fs.stat(path); + const bytes = new Uint8Array(stat.size); + const fd = fs.open(path, 0, 0); + try { + expect(fs.read(fd, bytes, null, bytes.length)).toBe(bytes.length); + } finally { + fs.close(fd); + } + return decoder.decode(bytes); +} + +describe("kernel-owned browser image assembly", () => { + it("recursively merges canonical /etc while preserving caller leaves", async () => { + const source = createEmptyBuildFs(); + source.mkdirWithOwner("/etc", 0o755, 0, 0); + source.mkdirWithOwner("/etc/ssl", 0o750, 12, 34); + addFile(source, "/etc/hosts", "canonical hosts\n"); + addFile(source, "/etc/ssl/openssl.cnf", "canonical config\n"); + addFile(source, "/etc/ssl/cert.pem", "canonical cert\n", 0o640, 12, 34); + source.symlinkWithOwner("cert.pem", "/etc/ssl/current.pem", 12, 34); + + const target = createEmptyBuildFs(); + target.mkdirWithOwner("/etc", 0o755, 0, 0); + target.mkdirWithOwner("/etc/ssl", 0o755, 1000, 1000); + addFile(target, "/etc/ssl/openssl.cnf", "demo config\n", 0o600, 1000, 1000); + + overlayEtcFromRootfs(target, await source.saveImage()); + + expect(readFile(target, "/etc/hosts")).toBe("canonical hosts\n"); + expect(readFile(target, "/etc/ssl/cert.pem")).toBe("canonical cert\n"); + expect(readFile(target, "/etc/ssl/openssl.cnf")).toBe("demo config\n"); + expect(target.readlink("/etc/ssl/current.pem")).toBe("cert.pem"); + expect(target.stat("/etc/ssl/cert.pem")).toMatchObject({ + mode: expect.any(Number), + uid: 12, + gid: 34, + }); + expect(target.stat("/etc/ssl/cert.pem").mode & 0o7777).toBe(0o640); + expect(target.stat("/etc/ssl/openssl.cnf")).toMatchObject({ + uid: 1000, + gid: 1000, + }); + }); + + it("fails loudly when the canonical image has no /etc tree", async () => { + const source = createEmptyBuildFs(); + const target = createEmptyBuildFs(); + const image = await source.saveImage(); + + expect(() => overlayEtcFromRootfs(target, image)).toThrow(); + }); +}); diff --git a/host/test/lazy-archive.test.ts b/host/test/lazy-archive.test.ts index 5e18ba65a3..218860ac81 100644 --- a/host/test/lazy-archive.test.ts +++ b/host/test/lazy-archive.test.ts @@ -5,6 +5,8 @@ import { parseZipCentralDirectory } from "../src/vfs/zip"; import type { ZipEntry } from "../src/vfs/zip"; const O_RDONLY = 0x0000; +const O_WRONLY = 0x0001; +const O_TRUNC = 0x0200; function createMemfs(): MemoryFileSystem { const sab = new SharedArrayBuffer(4 * 1024 * 1024); @@ -61,9 +63,170 @@ function makeRealZip() { return { zipBytes, entries: parseZipCentralDirectory(zipBytes) }; } +function makeTwoMemberZip() { + const zipBytes = zipSync({ + "a.txt": new TextEncoder().encode("alpha"), + "b.txt": new TextEncoder().encode("bravo"), + }); + return { zipBytes, entries: parseZipCentralDirectory(zipBytes) }; +} + +function readText(mfs: MemoryFileSystem, path: string): string { + const fd = mfs.open(path, O_RDONLY, 0); + const buffer = new Uint8Array(64); + const read = mfs.read(fd, buffer, null, buffer.length); + mfs.close(fd); + return new TextDecoder().decode(buffer.subarray(0, read)); +} + // --- Task 3: Registration --- describe("Lazy archive group registration", () => { + it("atomically replaces an existing file with archive backing", async () => { + const originalFetch = globalThis.fetch; + const mfs = createMemfs(); + const { zipBytes, entries } = makeRealZip(); + mfs.mkdir("/opt", 0o755); + mfs.mkdir("/opt/bin", 0o755); + mfs.createFileWithOwner("/opt/bin/hello", 0o644, 0, 0, new Uint8Array([9])); + + mfs.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + + try { + await expect(mfs.ensureMaterialized("/opt/bin/hello")).resolves.toBe( + true, + ); + expect(readText(mfs, "/opt/bin/hello")).toBe("#!/bin/sh\necho hello"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not overwrite a peer write after replacing an archive path", async () => { + const originalFetch = globalThis.fetch; + const mfs = createMemfs(); + const peer = MemoryFileSystem.fromExisting(mfs.sharedBuffer); + const { zipBytes, entries } = makeRealZip(); + mfs.mkdir("/opt", 0o755); + mfs.mkdir("/opt/bin", 0o755); + mfs.createFileWithOwner("/opt/bin/hello", 0o644, 0, 0, new Uint8Array([4])); + + const raw = ( + mfs as unknown as { + fs: { + createLazyStub: ( + path: string, + mode: number, + ) => { ino: number; generation: number; dataSequence: number }; + }; + } + ).fs; + const createLazyStub = raw.createLazyStub.bind(raw); + const createSpy = vi + .spyOn(raw, "createLazyStub") + .mockImplementation((path, mode) => { + const identity = createLazyStub(path, mode); + if (path === "/opt/bin/hello") { + const writer = peer.open(path, O_WRONLY, 0o644); + peer.write(writer, new Uint8Array([9]), null, 1); + peer.close(writer); + } + return identity; + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + + try { + mfs.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + await expect(mfs.ensureMaterialized("/opt/bin/hello")).resolves.toBe( + true, + ); + const fd = mfs.open("/opt/bin/hello", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(mfs.read(fd, byte, null, 1)).toBe(1); + mfs.close(fd); + expect(byte[0]).toBe(9); + } finally { + createSpy.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("replaces stale standalone backing when a path moves into an archive", async () => { + const originalFetch = globalThis.fetch; + const mfs = createMemfs(); + const zipBytes = zipSync({ item: new Uint8Array([7]) }); + mfs.registerLazyFile("/item", "http://example.com/old", 1); + mfs.registerLazyArchiveFromEntries( + "http://example.com/archive.zip", + parseZipCentralDirectory(zipBytes), + "/", + ); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + globalThis.fetch = fetchMock; + + try { + await expect(mfs.ensureMaterialized("/item")).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith("http://example.com/archive.zip"); + const fd = mfs.open("/item", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(mfs.read(fd, byte, null, 1)).toBe(1); + mfs.close(fd); + expect(byte[0]).toBe(7); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("replaces stale archive backing when a member becomes standalone", async () => { + const originalFetch = globalThis.fetch; + const mfs = createMemfs(); + const zipBytes = zipSync({ item: new Uint8Array([7]) }); + mfs.registerLazyArchiveFromEntries( + "http://example.com/archive.zip", + parseZipCentralDirectory(zipBytes), + "/", + ); + mfs.registerLazyFile("/item", "http://example.com/standalone", 1); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([8]).buffer), + } as unknown as Response); + globalThis.fetch = fetchMock; + + try { + expect(mfs.exportLazyArchiveEntries()).toEqual([]); + await expect(mfs.ensureMaterialized("/item")).resolves.toBe(true); + expect(fetchMock).toHaveBeenCalledWith("http://example.com/standalone"); + const fd = mfs.open("/item", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(mfs.read(fd, byte, null, 1)).toBe(1); + mfs.close(fd); + expect(byte[0]).toBe(8); + } finally { + globalThis.fetch = originalFetch; + } + }); + it("registerLazyArchiveFromEntries creates stubs for all files", () => { const mfs = createMemfs(); const entries = makeFakeEntries(); @@ -404,6 +567,85 @@ describe("Lazy archive materialization", () => { const st = mfs.stat("/opt/bin/hello"); expect(st.mode & 0o777).toBe(0o700); }); + + it("keeps a peer-renamed member lazy when another archive member materializes", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const owner = MemoryFileSystem.create(sab); + const peer = MemoryFileSystem.fromExisting(sab); + const { zipBytes, entries } = makeTwoMemberZip(); + + owner.registerLazyArchiveFromEntries( + "http://example.com/two-members.zip", + entries, + "/pkg", + ); + peer.rename("/pkg/b.txt", "/pkg/moved-b.txt"); + + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + + await expect(owner.ensureMaterialized("/pkg/a.txt")).resolves.toBe(true); + expect(readText(owner, "/pkg/a.txt")).toBe("alpha"); + + await expect(owner.ensureMaterialized("/pkg/moved-b.txt")).resolves.toBe( + true, + ); + expect(readText(owner, "/pkg/moved-b.txt")).toBe("bravo"); + }); + + it("finishes a requested archive member renamed during its fetch", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const owner = MemoryFileSystem.create(sab); + const peer = MemoryFileSystem.fromExisting(sab); + const { zipBytes, entries } = makeTwoMemberZip(); + owner.registerLazyArchiveFromEntries( + "http://example.com/two-members.zip", + entries, + "/pkg", + ); + let release!: (value: ArrayBuffer) => void; + const body = new Promise((resolve) => { + release = resolve; + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => body, + } as unknown as Response); + + const pending = owner.ensureMaterialized("/pkg/a.txt"); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + peer.rename("/pkg/a.txt", "/pkg/moved-a.txt"); + release(zipBytes.buffer); + + await expect(pending).resolves.toBe(true); + expect(readText(owner, "/pkg/moved-a.txt")).toBe("alpha"); + }); + + it("materializes pending archives into a self-contained image", async () => { + const mfs = createMemfs(); + const { zipBytes, entries } = makeRealZip(); + mfs.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + const fetchMock = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + globalThis.fetch = fetchMock; + + const restored = MemoryFileSystem.fromImage( + await mfs.saveImage({ materializeAll: true }), + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(restored.exportLazyArchiveEntries()).toEqual([]); + expect(readText(restored, "/opt/bin/hello")).toBe("#!/bin/sh\necho hello"); + expect(readText(restored, "/opt/share/data.txt")).toBe("hello world"); + }); }); // --- Task 5: Unlink tracking --- @@ -467,6 +709,60 @@ describe("Lazy archive unlink tracking", () => { expect(new TextDecoder().decode(buf.subarray(0, n))).toBe("hello world"); }); + it("retains a peer-created hard-link alias after unlink", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const owner = MemoryFileSystem.create(sab); + const peer = MemoryFileSystem.fromExisting(sab); + const { zipBytes, entries } = makeRealZip(); + owner.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + peer.link("/opt/bin/hello", "/archive-alias"); + + owner.unlink("/opt/bin/hello"); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + + await expect(owner.ensureMaterialized("/archive-alias")).resolves.toBe( + true, + ); + expect(readText(owner, "/archive-alias")).toBe("#!/bin/sh\necho hello"); + }); + + it("retains a peer-created archive alias when rename replaces its other name", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const owner = MemoryFileSystem.create(sab); + const peer = MemoryFileSystem.fromExisting(sab); + const { zipBytes, entries } = makeRealZip(); + owner.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + peer.link("/opt/bin/hello", "/archive-alias"); + owner.createFileWithOwner("/source", 0o644, 0, 0, new Uint8Array([4])); + + owner.rename("/source", "/opt/bin/hello"); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + + await expect(owner.ensureMaterialized("/archive-alias")).resolves.toBe( + true, + ); + expect(readText(owner, "/archive-alias")).toBe("#!/bin/sh\necho hello"); + const destination = owner.open("/opt/bin/hello", O_RDONLY, 0); + const destinationByte = new Uint8Array(1); + expect(owner.read(destination, destinationByte, null, 1)).toBe(1); + owner.close(destination); + expect(destinationByte[0]).toBe(4); + }); + it("unlink of non-archive file does not affect archive groups", () => { const mfs = createMemfs(); const entries = makeFakeEntries(); @@ -530,6 +826,65 @@ describe("Lazy archive export/import", () => { expect(mfs2.stat("/usr/share/vim/README").size).toBe(0); }); + it("retains the original archive member name when importing legacy metadata", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const owner = MemoryFileSystem.create(sab); + const { zipBytes, entries } = makeRealZip(); + owner.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + + const serialized = owner.exportLazyArchiveEntries(); + for (const entry of serialized[0].entries) delete entry.archivePath; + + const restoredMetadata = MemoryFileSystem.fromExisting(sab); + restoredMetadata.importLazyArchiveEntries(serialized); + restoredMetadata.rename("/opt/bin/hello", "/opt/bin/moved"); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + arrayBuffer: () => Promise.resolve(zipBytes.buffer), + } as unknown as Response); + + await expect( + restoredMetadata.ensureMaterialized("/opt/bin/moved"), + ).resolves.toBe(true); + expect(readText(restoredMetadata, "/opt/bin/moved")).toBe( + "#!/bin/sh\necho hello", + ); + }); + + it("rejects sequence-less archive metadata after a live peer write", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const owner = MemoryFileSystem.create(sab); + const { entries } = makeRealZip(); + owner.registerLazyArchiveFromEntries( + "http://example.com/test.zip", + entries, + "/opt", + ); + const serialized = owner.exportLazyArchiveEntries(); + const hello = serialized[0].entries.find( + (entry) => entry.vfsPath === "/opt/bin/hello", + )!; + delete hello.dataSequence; + + const writer = owner.open("/opt/bin/hello", O_WRONLY | O_TRUNC, 0o644); + owner.write(writer, new Uint8Array([9]), null, 1); + owner.close(writer); + + const peer = MemoryFileSystem.fromExisting(sab); + expect(() => peer.importLazyArchiveEntries(serialized)).toThrow( + /requires inode generation and data sequence/, + ); + + expect(peer.stat("/opt/bin/hello").size).toBe(1); + await expect(peer.ensureMaterialized("/opt/bin/hello")).resolves.toBe( + false, + ); + }); + it("rebaseToNewFileSystem preserves unmaterialized archive metadata", () => { const mfs = createMemfs(); const entries = makeFakeEntries(); @@ -557,7 +912,7 @@ describe("Lazy archive export/import", () => { rebased.close(fd); }); - it("import of materialized group does not populate lazyArchiveInodes", async () => { + it("omits fully materialized groups from deferred archive metadata", async () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const mfs1 = MemoryFileSystem.create(sab); const { zipBytes, entries } = makeRealZip(); @@ -576,10 +931,9 @@ describe("Lazy archive export/import", () => { await mfs1.ensureMaterialized("/opt/bin/hello"); - // Export from instance 1 — group should be materialized + // Concrete files no longer need archive metadata in another instance. const serialized = mfs1.exportLazyArchiveEntries(); - expect(serialized).toHaveLength(1); - expect(serialized[0].materialized).toBe(true); + expect(serialized).toEqual([]); // Import into instance 2 on the same SAB const mfs2 = MemoryFileSystem.fromExisting(sab); 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/mariadb-image-helpers.test.ts b/host/test/mariadb-image-helpers.test.ts new file mode 100644 index 0000000000..5fbed4d4a6 --- /dev/null +++ b/host/test/mariadb-image-helpers.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from "vitest"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { + MARIADB_DATA_DIRS, + MARIADB_DATA_MODE, + MARIADB_GID, + MARIADB_UID, + prepareMariadbWritableDirectories, +} from "../../images/vfs/scripts/mariadb-image-helpers"; + +describe("MariaDB VFS image ownership", () => { + it("round-trips writable mysql-owned data directories and sticky /tmp", async () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + prepareMariadbWritableDirectories(fs); + + const restored = MemoryFileSystem.fromImage(await fs.saveImage()); + + for (const dir of MARIADB_DATA_DIRS) { + expect(restored.stat(dir)).toMatchObject({ + uid: MARIADB_UID, + gid: MARIADB_GID, + }); + expect(restored.stat(dir).mode & 0o7777).toBe(MARIADB_DATA_MODE); + } + + expect(restored.stat("/tmp")).toMatchObject({ uid: 0, gid: 0 }); + expect(restored.stat("/tmp").mode & 0o7777).toBe(0o1777); + }); +}); diff --git a/host/test/mmap-tracking.test.ts b/host/test/mmap-tracking.test.ts new file mode 100644 index 0000000000..6ae2a2d9ca --- /dev/null +++ b/host/test/mmap-tracking.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +describe("MAP_SHARED host interval tracking", () => { + it("splits a mapping around a partial munmap", () => { + const worker = createWorker(); + worker.sharedMappings.set(7, new Map([ + [0x10000, { fd: 4, fileOffset: 0x2000, len: 0x30000 }], + ])); + + worker.cleanupSharedMappings(7, 0x20000, 0x10000); + + expect(Array.from(worker.sharedMappings.get(7)!.entries())).toEqual([ + [0x10000, { fd: 4, fileOffset: 0x2000, len: 0x10000 }], + [0x30000, { fd: 4, fileOffset: 0x22000, len: 0x10000 }], + ]); + }); + + it("moves and resizes file-backed metadata with mremap", () => { + const worker = createWorker(); + worker.sharedMappings.set(9, new Map([ + [0x40000, { fd: 5, fileOffset: 0x6000, len: 0x10000 }], + ])); + + worker.remapSharedMapping(9, 0x40000, 0x80000, 0x28000); + + expect(worker.sharedMappings.get(9)!.has(0x40000)).toBe(false); + expect(worker.sharedMappings.get(9)!.get(0x80000)).toEqual({ + fd: 5, + fileOffset: 0x6000, + len: 0x28000, + }); + }); + + it("retains mapping-level writeback eligibility after mprotect", () => { + const worker = createWorker(); + worker.sharedMappings.set(9, new Map([ + [0x40000, { + fd: 5, + fileOffset: 0x1000, + len: 0x30000, + writable: false, + }], + ])); + + worker.updateSharedMappingProtection(9, 0x50000, 0x10000, true); + worker.updateSharedMappingProtection(9, 0x50000, 0x10000, false); + + expect(worker.sharedMappings.get(9)!.get(0x40000)).toEqual({ + fd: 5, + fileOffset: 0x1000, + len: 0x30000, + writable: true, + }); + }); +}); + +function createWorker(): any { + return Object.assign(Object.create(CentralizedKernelWorker.prototype), { + sharedMappings: new Map(), + }); +} diff --git a/host/test/multi-worker.test.ts b/host/test/multi-worker.test.ts index c5bdd1d6b4..667fe83854 100644 --- a/host/test/multi-worker.test.ts +++ b/host/test/multi-worker.test.ts @@ -4,7 +4,12 @@ // setNextChildPid, and fork flow. import { describe, it, expect, vi } from "vitest"; import { readFileSync } from "node:fs"; -import { CAPTURED_STDIO, CentralizedKernelWorker } from "../src/kernel-worker"; +import { join } from "node:path"; +import { + CAPTURED_STDIO, + CentralizedKernelWorker, + shouldDeliverPosixTimerSignal, +} from "../src/kernel-worker"; import { resolveBinary } from "../src/binary-resolver"; import { NodePlatformIO } from "../src/platform/node"; import { SharedLockTable } from "../src/shared-lock-table"; @@ -16,9 +21,13 @@ import { import { CH_TOTAL_SIZE, DEFAULT_MAX_PAGES, WASM_PAGE_SIZE } from "../src/constants"; import { ABI_SYSCALLS, + CH_ARGS, + CH_ARG_SIZE, CH_DATA, CH_ERRNO, CH_RETURN, + CH_SYSCALL, + HOST_INTERCEPTED_SYSCALLS, } from "../src/generated/abi"; const MAX_PAGES = 1024; // 64 MiB: enough to prove initial < maximum. @@ -59,6 +68,12 @@ function registerProcess( } describe("CentralizedKernelWorker Process Management", () => { + it("does not deliver SIGEV_NONE as a signal-zero wakeup", () => { + expect(shouldDeliverPosixTimerSignal(0)).toBe(false); + expect(shouldDeliverPosixTimerSignal(14)).toBe(true); + expect(shouldDeliverPosixTimerSignal(65)).toBe(false); + }); + it("releases host-backed advisory locks when deactivating an exited process", () => { const pid = 126; const peerPid = 127; @@ -76,6 +91,8 @@ describe("CentralizedKernelWorker Process Management", () => { alarmTimers: new Map(), posixTimers: new Map(), pendingSleeps: new Map(), + pendingSignalWaits: new Map(), + signalWaitDeadlines: new Map(), lockTable, cleanupPendingPollRetries: vi.fn(), cleanupPendingSelectRetries: vi.fn(), @@ -92,6 +109,156 @@ describe("CentralizedKernelWorker Process Management", () => { expect((kw as any).hostReaped.has(pid)).toBe(false); }); + it("retries fork allocation when the kernel still owns a zombie pid", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); + const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + const kernelForkProcess = vi.fn((_parent: number, child: number) => + child === 100 ? -17 : 0, + ); + const completeChannel = vi.fn(); + const onFork = vi.fn(() => Promise.resolve([WASM_PAGE_SIZE])); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork }, + nextChildPid: 100, + processes: new Map([[parentPid, { channels: [channel] }]]), + threadForkContexts: new Map(), + sharedMappings: new Map(), + tcpListenerTargets: new Map(), + epollInterests: new Map(), + completeChannel, + kernelInstance: { + exports: { + kernel_fork_process: kernelForkProcess, + kernel_clear_fork_child: vi.fn(() => 0), + kernel_reset_signal_mask: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + }, + }, + }) as CentralizedKernelWorker; + + (kw as any).handleFork(channel, [0]); + await Promise.resolve(); + + expect(kernelForkProcess).toHaveBeenNthCalledWith(1, parentPid, 100); + expect(kernelForkProcess).toHaveBeenNthCalledWith(2, parentPid, 101); + expect(onFork).toHaveBeenCalledWith(parentPid, 101, memory, undefined); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + undefined, + 101, + 0, + ); + }); + + it("inherits child fd mirrors when the parent channel becomes stale during fork", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); + const oldChannel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + const replacementChannel = { + pid: parentPid, + channelOffset: 2 * WASM_PAGE_SIZE, + memory, + }; + const completeChannel = vi.fn(); + let finishFork!: (offsets: number[]) => void; + const forkLaunch = new Promise((resolve) => { + finishFork = resolve; + }); + const close = vi.fn(); + const listener = { + server: { close }, + pid: parentPid, + port: 8080, + connections: new Set(), + }; + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork: vi.fn(() => forkLaunch) }, + nextChildPid: 100, + processes: new Map([[parentPid, { channels: [replacementChannel] }]]), + threadForkContexts: new Map(), + sharedMappings: new Map(), + tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), + tcpListenerRRIndex: new Map([[8080, 0]]), + tcpVirtualListenerKeys: new Map(), + tcpListeners: new Map([[`${parentPid}:4`, listener]]), + tcpConnections: new Map(), + shmMappings: new Map(), + io: { network: undefined }, + epollInterests: new Map([[`${parentPid}:6`, [ + { fd: 8, events: 1, data: 11n }, + ]]]), + completeChannel, + kernelInstance: { + exports: { + kernel_fork_process: vi.fn(() => 0), + kernel_clear_fork_child: vi.fn(() => 0), + kernel_reset_signal_mask: vi.fn(() => 0), + kernel_get_process_exit_signal: vi.fn(() => -1), + }, + }, + }) as CentralizedKernelWorker; + + (kw as any).handleFork(oldChannel, [0]); + expect((kw as any).tcpListenerTargets.get(8080)).toContainEqual({ pid: 100, fd: 4 }); + (kw as any).cleanupTcpListeners(parentPid); + expect(close).not.toHaveBeenCalled(); + expect((kw as any).tcpListeners.has("100:4")).toBe(true); + finishFork([WASM_PAGE_SIZE]); + await Promise.resolve(); + + expect((kw as any).tcpListenerTargets.get(8080)).toEqual([{ pid: 100, fd: 4 }]); + expect((kw as any).epollInterests.get("100:6")).toEqual([ + { fd: 8, events: 1, data: 11n }, + ]); + expect(completeChannel).not.toHaveBeenCalled(); + }); + + it("removes eager child registrations and mirrors when fork worker launch fails", async () => { + const parentPid = 77; + const memory = new WebAssembly.Memory({ initial: 4, maximum: 4, shared: true }); + const channel = { pid: parentPid, channelOffset: WASM_PAGE_SIZE, memory }; + const completeChannel = vi.fn(); + const deactivateProcess = vi.fn(); + const removeProcess = vi.fn(() => 0); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onFork: vi.fn(() => Promise.reject(new Error("launch failed"))) }, + nextChildPid: 100, + processes: new Map([[parentPid, { channels: [channel] }]]), + threadForkContexts: new Map(), + tcpListenerTargets: new Map([[8080, [{ pid: parentPid, fd: 4 }]]]), + epollInterests: new Map(), + completeChannel, + deactivateProcess, + kernelInstance: { + exports: { + kernel_fork_process: vi.fn(() => 0), + kernel_clear_fork_child: vi.fn(() => 0), + kernel_reset_signal_mask: vi.fn(() => 0), + kernel_remove_process: removeProcess, + kernel_get_process_exit_signal: vi.fn(() => -1), + }, + }, + }) as CentralizedKernelWorker; + + (kw as any).handleFork(channel, [0]); + await Promise.resolve(); + await Promise.resolve(); + + expect(deactivateProcess).toHaveBeenCalledWith(100); + expect(removeProcess).toHaveBeenCalledWith(100); + expect(completeChannel).toHaveBeenCalledWith( + channel, + HOST_INTERCEPTED_SYSCALLS.SYS_FORK, + [0], + undefined, + -1, + 12, + ); + }); + it("completes pthread SYS_EXIT channels (clearing the exiting guest's atomic-wait waiter) even when the host terminates the worker", () => { // Regression guard for the reused-slot notify-steal deadlock. On thread // exit the kernel must flip the channel status word off CH_PENDING @@ -214,11 +381,14 @@ describe("CentralizedKernelWorker Process Management", () => { [pid, { memory, channels: [{ channelOffset: mainChannelOffset }, channel] }], ]), activeChannels: [channel], + pendingSleeps: new Map(), channelTids: new Map([[`${pid}:${threadChannelOffset}`, tid]]), threadForkContexts: new Map([ [`${pid}:${threadChannelOffset}`, { fnPtr: 1, argPtr: 2 }], ]), threadCtidPtrs: new Map([[`${pid}:${tid}`, ctidPtr]]), + pendingSignalWaits: new Map(), + signalWaitDeadlines: new Map(), notifyThreadExit: vi.fn(), }) as CentralizedKernelWorker; @@ -261,6 +431,7 @@ describe("CentralizedKernelWorker Process Management", () => { resolveClone = resolve; }); }); + const channel = { pid, channelOffset: mainChannelOffset, memory }; const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { callbacks: { onClone }, @@ -273,7 +444,7 @@ describe("CentralizedKernelWorker Process Management", () => { scratchOffset: 0, currentHandlePid: 0, processes: new Map([ - [pid, { channels: [{ channelOffset: mainChannelOffset }] }], + [pid, { channels: [channel] }], ]), threadCtidPtrs, completeChannel: vi.fn(), @@ -290,7 +461,7 @@ describe("CentralizedKernelWorker Process Management", () => { }); (kw as any).handleClone( - { pid, channelOffset: mainChannelOffset, memory }, + channel, [0, stackPtr, 0, tlsPtr, ctidPtr, 0], ); @@ -301,6 +472,67 @@ describe("CentralizedKernelWorker Process Management", () => { expect((kw as any).completeChannel).toHaveBeenCalled(); }); + it("does not erase replacement clear-TID metadata from a stale clone completion", async () => { + const pid = 126; + const tid = 79; + const oldMemory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const newMemory = new WebAssembly.Memory({ + initial: 16, + maximum: 16, + shared: true, + }); + const channelOffset = WASM_PAGE_SIZE; + const oldChannel = { pid, channelOffset, memory: oldMemory }; + const newChannel = { pid, channelOffset, memory: newMemory }; + const processView = new DataView(oldMemory.buffer, channelOffset); + processView.setUint32(CH_DATA, 11, true); + processView.setUint32(CH_DATA + 4, 22, true); + const kernelMemory = new WebAssembly.Memory({ initial: 1, maximum: 1 }); + const kernelView = new DataView(kernelMemory.buffer); + const threadCtidPtrs = new Map(); + let resolveClone!: (value: number) => void; + const onClone = vi.fn(() => new Promise((resolve) => { + resolveClone = resolve; + })); + const completeChannel = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + callbacks: { onClone }, + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelMemory, + scratchOffset: 0, + currentHandlePid: 0, + processes: new Map([[pid, { channels: [oldChannel] }]]), + threadCtidPtrs, + completeChannel, + bindKernelTidForChannel: vi.fn(), + kernelInstance: { + exports: { + kernel_handle_channel: vi.fn(() => { + kernelView.setBigInt64(CH_RETURN, BigInt(tid), true); + kernelView.setUint32(CH_ERRNO, 0, true); + return 0; + }), + }, + }, + }); + + (kw as any).handleClone( + oldChannel, + [0, 0x00800000, 0, 0x00900000, 0x00040000, 0], + ); + (kw as any).processes.set(pid, { channels: [newChannel] }); + threadCtidPtrs.set(`${pid}:${tid}`, 0x00050000); + resolveClone(tid); + await Promise.resolve(); + + expect(threadCtidPtrs.get(`${pid}:${tid}`)).toBe(0x00050000); + expect(completeChannel).not.toHaveBeenCalled(); + }); + it("does not lower compact process max_addr when adding dynamic pthread channels", () => { const setMaxAddr = vi.fn(() => 0); const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { @@ -399,6 +631,133 @@ describe("CentralizedKernelWorker Process Management", () => { kw.unregisterProcess(999); }); + it("closes live host file handles when unregistering a process", async () => { + const io = new NodePlatformIO(); + const open = vi.spyOn(io, "open"); + const close = vi.spyOn(io, "close"); + const kw = new CentralizedKernelWorker( + { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, + io, + ); + await kw.init(loadKernelWasm()); + + const pid = 150; + const procMemory = createProcessMemory(); + registerProcess(kw, pid, procMemory); + + // Issue open(2) directly through the real kernel export so the Rust + // Process owns the exact host handle that unregisterProcess must release. + const kernelMemory = (kw as any).kernelMemory as WebAssembly.Memory; + const scratchOffset = (kw as any).scratchOffset as number; + const pathPtr = scratchOffset + CH_DATA; + const path = new TextEncoder().encode( + `${join(process.cwd(), "../Cargo.toml")}\0`, + ); + new Uint8Array(kernelMemory.buffer).set(path, pathPtr); + const channel = new DataView(kernelMemory.buffer, scratchOffset); + channel.setUint32(CH_SYSCALL, ABI_SYSCALLS.Open, true); + for (let i = 0; i < 6; i++) { + channel.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + channel.setBigInt64(CH_ARGS, BigInt(pathPtr), true); + const handleChannel = (kw as any).kernelInstance.exports + .kernel_handle_channel as (offset: number, pid: number) => number; + handleChannel(kw.toKernelPtr(scratchOffset) as number, pid); + + expect(channel.getUint32(CH_ERRNO, true)).toBe(0); + expect(Number(channel.getBigInt64(CH_RETURN, true))).toBeGreaterThanOrEqual( + 3, + ); + expect(open).toHaveBeenCalledOnce(); + const hostHandle = open.mock.results[0].value; + expect(close).not.toHaveBeenCalledWith(hostHandle); + + kw.unregisterProcess(pid); + + expect(close).toHaveBeenCalledWith(hostHandle); + }); + + it("releases a retained mmap handle before forced descriptor teardown", async () => { + const io = new NodePlatformIO(); + const open = vi.spyOn(io, "open"); + const close = vi.spyOn(io, "close"); + const kw = new CentralizedKernelWorker( + { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, + io, + ); + await kw.init(loadKernelWasm()); + + const pid = 151; + const procMemory = createProcessMemory(); + registerProcess(kw, pid, procMemory); + const kernelMemory = (kw as any).kernelMemory as WebAssembly.Memory; + const scratchOffset = (kw as any).scratchOffset as number; + const pathPtr = scratchOffset + CH_DATA; + const path = new TextEncoder().encode( + `${join(process.cwd(), "../Cargo.toml")}\0`, + ); + new Uint8Array(kernelMemory.buffer).set(path, pathPtr); + const channel = new DataView(kernelMemory.buffer, scratchOffset); + channel.setUint32(CH_SYSCALL, ABI_SYSCALLS.Open, true); + for (let i = 0; i < 6; i++) { + channel.setBigInt64(CH_ARGS + i * CH_ARG_SIZE, 0n, true); + } + channel.setBigInt64(CH_ARGS, BigInt(pathPtr), true); + const handleChannel = (kw as any).kernelInstance.exports + .kernel_handle_channel as (offset: number, pid: number) => number; + handleChannel(kw.toKernelPtr(scratchOffset) as number, pid); + const guestFd = Number(channel.getBigInt64(CH_RETURN, true)); + expect(channel.getUint32(CH_ERRNO, true)).toBe(0); + expect(guestFd).toBeGreaterThanOrEqual(3); + const hostHandle = open.mock.results[0].value; + const stat = io.fstat(hostHandle); + const backingKey = io.fileHandleIdentity( + hostHandle, + BigInt(stat.dev), + BigInt(stat.ino), + )!; + + const retainedKernel = (kw as any).kernel; + retainedKernel.retainHostFileHandle(hostHandle); + const release = vi.spyOn(retainedKernel, "releaseHostFileHandle"); + (kw as any).sharedMmapBackings.set(backingKey, { + key: backingKey, + handle: hostHandle, + writable: false, + size: stat.size, + sizeValid: true, + pages: new Map(), + dirtyPages: new Set(), + refCount: 1, + version: 0, + }); + (kw as any).sharedMappings.set(pid, new Map([[0x1000, { + fd: guestFd, + fileOffset: 0, + len: 4096, + writable: false, + writeAllowed: false, + backingKind: "file", + backingKey, + snapshot: new Uint8Array(4096), + seenVersion: 0, + }]])); + + kw.unregisterProcess(pid); + + expect(release).toHaveBeenCalledWith(hostHandle); + expect(close).toHaveBeenCalledWith(hostHandle); + const hostCloseCall = close.mock.calls.findIndex(([handle]) => handle === hostHandle); + expect(hostCloseCall).toBeGreaterThanOrEqual(0); + expect(release.mock.invocationCallOrder[0]!).toBeLessThan( + close.mock.invocationCallOrder[hostCloseCall]!, + ); + expect((kw as any).sharedMappings.has(pid)).toBe(false); + expect((kw as any).sharedMmapBackings.has(backingKey)).toBe(false); + expect((retainedKernel as any).retainedHostFileHandles.size).toBe(0); + expect(open).toHaveBeenCalledOnce(); + }); + it("repeated compact-layout launches do not leave process registrations behind", async () => { const kw = new CentralizedKernelWorker( { maxWorkers: 4, dataBufferSize: 65536, useSharedMemory: true }, diff --git a/host/test/network-hostname.test.ts b/host/test/network-hostname.test.ts new file mode 100644 index 0000000000..9982cd3f9f --- /dev/null +++ b/host/test/network-hostname.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; +import { + parseNumericIpv4Hostname, + validateDnsHostname, +} from "../src/networking/hostname"; + +describe("network hostname parsing", () => { + it.each([ + ["2130706433", [127, 0, 0, 1]], + ["127.1", [127, 0, 0, 1]], + ["127.1.1", [127, 1, 0, 1]], + ["127.0.0.1", [127, 0, 0, 1]], + ["010.010.010.010", [10, 10, 10, 10]], + ["4294967295", [255, 255, 255, 255]], + ["255.16777215", [255, 255, 255, 255]], + ["255.255.65535", [255, 255, 255, 255]], + ])("parses the decimal IPv4 form %s", (hostname, expected) => { + expect(Array.from(parseNumericIpv4Hostname(hostname)!)).toEqual(expected); + }); + + it.each([ + "4294967296", + "256.1", + "1.16777216", + "1.256.1", + "1.2.65536", + "1.2.3.256", + ])("rejects the overflowing IPv4 form %s", (hostname) => { + expect(() => parseNumericIpv4Hostname(hostname)).toThrow("ENOENT"); + }); + + it.each([ + ".", + ".1", + "1.", + "1..2", + "1.2.3.4.5", + ])("rejects the malformed numeric-looking name %s", (hostname) => { + expect(() => parseNumericIpv4Hostname(hostname)).toThrow("ENOENT"); + }); + + it("leaves ordinary DNS names for DNS validation", () => { + expect(parseNumericIpv4Hostname("example.com")).toBeNull(); + }); + + it("validates DNS wire lengths and preserves a trailing root dot", () => { + const longestName = [63, 63, 63, 61] + .map((length) => "a".repeat(length)) + .join("."); + + expect(() => validateDnsHostname("example.com")).not.toThrow(); + expect(() => validateDnsHostname("example.com.")).not.toThrow(); + expect(() => validateDnsHostname(longestName)).not.toThrow(); + expect(() => validateDnsHostname(`${longestName}.`)).not.toThrow(); + }); + + it.each([ + `www.${"x".repeat(64)}.com`, + [63, 63, 63, 62].map((length) => "x".repeat(length)).join("."), + ".example.com", + "example..com", + "-example.com", + "example-.com", + "münich.example", + ])("rejects the invalid DNS hostname %s", (hostname) => { + expect(() => validateDnsHostname(hostname)).toThrow("ENOENT"); + }); +}); diff --git a/host/test/node-host-mounts.test.ts b/host/test/node-host-mounts.test.ts index 93ffd80f9e..ba0bc0c0aa 100644 --- a/host/test/node-host-mounts.test.ts +++ b/host/test/node-host-mounts.test.ts @@ -14,6 +14,9 @@ * unmounted /no/such/mount returns ENOENT (proves the mount router * enforces VFS-only-lens — no fallthrough to the host fs). * + * path-resolution resolves symlinks and dot-dot component-wise across the + * `/tmp` HostFS and root MemoryFS mount boundary. + * * custom overriding `io` opts out of the default mount setup — * /etc/services is no longer reachable via the rootfs image. */ @@ -24,18 +27,49 @@ import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { runCentralizedProgram } from "./centralized-test-helper"; import { NodePlatformIO } from "../src/platform/node"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "../.."); const probeWasm = join(repoRoot, "examples/mount_probe_test.wasm"); const rootfsImage = join(repoRoot, "host/wasm/rootfs.vfs"); const servicesSource = join(repoRoot, "images/rootfs/etc/services"); -const caCertSource = join(repoRoot, "packages/registry/openssl/cacert.pem"); +const caCertSource = join(repoRoot, "images/rootfs/etc/ssl/cert.pem"); +const opensslConfigSource = join(repoRoot, "images/rootfs/etc/ssl/openssl.cnf"); const haveProbe = existsSync(probeWasm); const haveRootfs = existsSync(rootfsImage); describe.skipIf(!haveProbe || !haveRootfs)("node-host default mount setup", () => { + it("stores exact root-owned OpenSSL files in the canonical image", () => { + const fs = MemoryFileSystem.fromImage( + new Uint8Array(readFileSync(rootfsImage)), + ); + for (const [path, source] of [ + ["/etc/ssl/openssl.cnf", opensslConfigSource], + ["/etc/ssl/cert.pem", caCertSource], + ["/etc/ssl/certs/ca-certificates.crt", caCertSource], + ] as const) { + const expected = readFileSync(source); + const st = fs.stat(path); + expect(st.mode & 0xf000).toBe(0x8000); + expect(st.mode & 0o7777).toBe(0o644); + expect(st.uid).toBe(0); + expect(st.gid).toBe(0); + expect(st.size).toBe(expected.byteLength); + + const actual = new Uint8Array(st.size); + const fd = fs.open(path, 0, 0); + try { + expect(fs.read(fd, actual, null, actual.length)).toBe(actual.length); + } finally { + fs.close(fd); + } + expect(Buffer.from(actual)).toEqual(expected); + } + }); + it("stat + read /etc/services from the mounted rootfs image", async () => { const expected = readFileSync(servicesSource); const expectedHead = Array.from(expected.subarray(0, 16)) @@ -65,15 +99,19 @@ describe.skipIf(!haveProbe || !haveRootfs)("node-host default mount setup", () = expect(result.stdout).toContain("content=scratch-mount-roundtrip"); }); - it("installs the default OpenSSL CA bundle for Node VFS boots", async () => { - const expected = readFileSync(caCertSource); + it.each([ + ["/etc/ssl/openssl.cnf", opensslConfigSource], + ["/etc/ssl/cert.pem", caCertSource], + ["/etc/ssl/certs/ca-certificates.crt", caCertSource], + ])("serves rootfs-owned OpenSSL data at %s", async (path, source) => { + const expected = readFileSync(source); const expectedHead = Array.from(expected.subarray(0, 16)) .map((b) => b.toString(16).padStart(2, "0")) .join(""); const result = await runCentralizedProgram({ programPath: probeWasm, - argv: ["mount_probe_test", "rootfs", "/etc/ssl/certs/ca-certificates.crt"], + argv: ["mount_probe_test", "rootfs", path], timeout: 10_000, }); @@ -82,6 +120,38 @@ describe.skipIf(!haveProbe || !haveRootfs)("node-host default mount setup", () = expect(result.stdout).toContain(`head=${expectedHead}`); }); + it.each(["/etc/ssl/openssl.cnf", "/etc/ssl/cert.pem"])( + "does not fabricate caller-missing OpenSSL data at %s", + async (path) => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(1024 * 1024)); + fs.mkdir("/etc", 0o755); + const image = await fs.saveImage(); + const program = readFileSync(probeWasm); + let stdout = ""; + const host = new NodeKernelHost({ + rootfsImage: image, + onStdout: (_pid, data) => { + stdout += new TextDecoder().decode(data); + }, + }); + + try { + await host.init(); + const exitCode = await host.spawn( + program.buffer.slice( + program.byteOffset, + program.byteOffset + program.byteLength, + ), + ["mount_probe_test", "rootfs", path], + ); + expect(exitCode).toBe(1); + expect(stdout).toContain("ROOTFS stat-errno=2"); + } finally { + await host.destroy(); + } + }, + ); + it("returns ENOENT for paths outside every mount (no fallthrough)", async () => { const result = await runCentralizedProgram({ programPath: probeWasm, @@ -94,6 +164,65 @@ describe.skipIf(!haveProbe || !haveRootfs)("node-host default mount setup", () = expect(result.stdout).toContain("UNMOUNTED errno=2"); }); + it("resolves pathname components across scratch and rootfs mounts", async () => { + const result = await runCentralizedProgram({ + programPath: probeWasm, + argv: [ + "mount_probe_test", + "path-resolution", + "/tmp/kandelo-path-resolution", + ], + timeout: 10_000, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PATH_RESOLUTION_PASS"); + expect(result.stderr).toBe(""); + }); + + it("rejects invalid initial cwd without leaking a registered process", async () => { + const program = readFileSync(probeWasm); + const programBytes = program.buffer.slice( + program.byteOffset, + program.byteOffset + program.byteLength, + ); + const host = new NodeKernelHost({ rootfsImage: "default" }); + + try { + await host.init(); + // The kernel creates its persistent init process when the first guest is + // registered, not when the worker itself is initialized. Warm that path + // before taking the baseline so the assertion below measures only the + // failed child registration. + expect( + await host.spawn(programBytes, [ + "mount_probe_test", + "rootfs", + "/etc/services", + ]), + ).toBe(0); + const before = await host.enumProcs(); + + await expect( + host.spawn(programBytes, ["mount_probe_test", "rootfs", "/etc/services"], { + cwd: "/no/such/cwd", + }), + ).rejects.toThrow(/setCwd failed for pid \d+: errno 2/); + expect(await host.enumProcs()).toEqual(before); + + await expect( + host.spawn(programBytes, ["mount_probe_test", "rootfs", "/etc/services"], { + cwd: "/root", + uid: 1000, + gid: 1000, + }), + ).rejects.toThrow(/setCwd failed for pid \d+: errno 13/); + expect(await host.enumProcs()).toEqual(before); + } finally { + await host.destroy(); + } + }); + it("custom `io` override bypasses the default mount setup", async () => { // NodePlatformIO has no synthetic /etc/services entry and the host // filesystem doesn't have one at the root either, so it must fail. diff --git a/host/test/node-host-vfs-only-metadata.test.ts b/host/test/node-host-vfs-only-metadata.test.ts index c5b0ee8b3b..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"); @@ -272,6 +297,26 @@ describe.each(backendFactories)("%s", (_name, makeCase) => { }); }); +describe("HostFileSystem default virtual ownership", () => { + it("can present existing host-backed files as owned by a chosen guest uid/gid", () => { + const root = makeTempRoot("wasm-posix-host-fs-default-owner-"); + const native = join(root, "owned-by-mount"); + writeFileSync(native, "data"); + const before = statSync(native); + + const backend = new HostFileSystem(root, "/", { uid: 65534, gid: 65533 }); + const virtual = backend.stat("/owned-by-mount"); + expect(virtual.uid).toBe(65534); + expect(virtual.gid).toBe(65533); + + backend.chown("/owned-by-mount", 1000, 1001); + const changed = backend.stat("/owned-by-mount"); + expect(changed.uid).toBe(1000); + expect(changed.gid).toBe(1001); + expectNativeMetadataUnchanged(native, before); + }); +}); + describe("VirtualPlatformIO on Node host mounts", () => { it("routes metadata operations to HostFileSystem as VFS-only changes", () => { const root = makeTempRoot("wasm-posix-virtual-platform-vfs-only-"); diff --git a/host/test/node-kernel-host-diagnostic.test.ts b/host/test/node-kernel-host-diagnostic.test.ts new file mode 100644 index 0000000000..0440e45eed --- /dev/null +++ b/host/test/node-kernel-host-diagnostic.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; +import { NodeKernelHost } from "../src/node-kernel-host"; +import type { KernelToMainMessage } from "../src/node-kernel-protocol"; + +interface TestableNodeKernelHost { + handleWorkerMessage(message: KernelToMainMessage): void; +} + +describe("NodeKernelHost diagnostics", () => { + it("delivers host diagnostics without invoking the guest stderr callback", () => { + const onHostDiagnostic = vi.fn(); + const onStderr = vi.fn(); + const host = new NodeKernelHost({ onHostDiagnostic, onStderr }); + const testable = host as unknown as TestableNodeKernelHost; + + testable.handleWorkerMessage({ + type: "host_diagnostic", + pid: 42, + source: "clone allocation", + message: "host allocation failed", + }); + + expect(onHostDiagnostic).toHaveBeenCalledOnce(); + expect(onHostDiagnostic).toHaveBeenCalledWith({ + pid: 42, + source: "clone allocation", + message: "host allocation failed", + }); + expect(onStderr).not.toHaveBeenCalled(); + }); + + it("keeps actual stderr messages on the guest callback", () => { + const onHostDiagnostic = vi.fn(); + const onStderr = vi.fn(); + const host = new NodeKernelHost({ onHostDiagnostic, onStderr }); + const testable = host as unknown as TestableNodeKernelHost; + const data = new TextEncoder().encode("guest stderr\n"); + + testable.handleWorkerMessage({ type: "stderr", pid: 7, data }); + + expect(onStderr).toHaveBeenCalledWith(7, data); + expect(onHostDiagnostic).not.toHaveBeenCalled(); + }); +}); diff --git a/host/test/node-platform-io-uid-gid.test.ts b/host/test/node-platform-io-uid-gid.test.ts index c3beec43ef..d483a7017c 100644 --- a/host/test/node-platform-io-uid-gid.test.ts +++ b/host/test/node-platform-io-uid-gid.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { chmodSync, fstatSync, mkdtempSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { chmodSync, fstatSync, lstatSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { NodePlatformIO } from "../src/platform/node"; @@ -88,6 +88,33 @@ describe("NodePlatformIO uid/gid normalization", () => { expect(nativeAfter.gid).toBe(nativeBefore.gid); }); + it("lchown updates only symlink metadata, including for dangling links", () => { + const target = join(dir, "lchown-target.txt"); + const link = join(dir, "lchown-link"); + const dangling = join(dir, "lchown-dangling"); + writeFileSync(target, "target"); + symlinkSync(target, link); + symlinkSync(join(dir, "missing-target"), dangling); + const nativeLinkBefore = lstatSync(link); + const nativeTargetBefore = statSync(target); + + const io = new NodePlatformIO(); + io.lchown(link, 1234, 5678); + io.lchown(dangling, 2345, 6789); + + expect(io.lstat(link)).toMatchObject({ uid: 1234, gid: 5678 }); + expect(io.stat(link)).toMatchObject({ uid: 0, gid: 0 }); + expect(io.lstat(dangling)).toMatchObject({ uid: 2345, gid: 6789 }); + expect(lstatSync(link)).toMatchObject({ + uid: nativeLinkBefore.uid, + gid: nativeLinkBefore.gid, + }); + expect(statSync(target)).toMatchObject({ + uid: nativeTargetBefore.uid, + gid: nativeTargetBefore.gid, + }); + }); + it("fchmod and fchown update virtual metadata without native changes", () => { const p = join(dir, "fd.txt"); writeFileSync(p, "hi"); 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/package-runtime-file.test.ts b/host/test/package-runtime-file.test.ts new file mode 100644 index 0000000000..47dc490071 --- /dev/null +++ b/host/test/package-runtime-file.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from "vitest"; +import { + parsePackageRuntimeFileContract, + readPackageRuntimeFileContract, +} from "../../scripts/package-runtime-file"; +import { findRepoRoot } from "../src/binary-resolver"; + +function metadata(overrides: Record = {}): string { + return JSON.stringify({ + artifact: "icu.dat", + guest_path: "/usr/lib/php/icu.dat", + mode: 0o644, + mirror_path: "php/icu.dat", + closure_mirror_paths: ["php/php.wasm", "php/intl.so", "php/icu.dat"], + ...overrides, + }); +} + +describe("package runtime-file closure metadata", () => { + it("reports every declared PHP output and runtime file", () => { + const contract = readPackageRuntimeFileContract( + findRepoRoot(), + "php", + "icu.dat", + ); + expect(contract.closureMirrorPaths).toEqual([ + "php/php.wasm", + "php/php-fpm.wasm", + "php/opcache.so", + "php/curl.so", + "php/phar.so", + "php/zend_test.so", + "php/zip.so", + "php/intl.so", + "php/icu.dat", + ]); + }, 120_000); + + it("rejects duplicate or incomplete closure path metadata", () => { + expect(() => parsePackageRuntimeFileContract( + metadata({ + closure_mirror_paths: ["php/php.wasm", "php/icu.dat", "php/icu.dat"], + }), + "php", + "icu.dat", + )).toThrow(/invalid runtime-file metadata/); + + expect(() => parsePackageRuntimeFileContract( + metadata({ closure_mirror_paths: ["php/php.wasm", "php/intl.so"] }), + "php", + "icu.dat", + )).toThrow(/invalid runtime-file metadata/); + }); + + it("rejects closure mirror traversal before binary resolution", () => { + expect(() => parsePackageRuntimeFileContract( + metadata({ + closure_mirror_paths: ["php/php.wasm", "../outside", "php/icu.dat"], + }), + "php", + "icu.dat", + )).toThrow(/invalid runtime-file metadata/); + }); +}); diff --git a/host/test/pathconf.test.ts b/host/test/pathconf.test.ts new file mode 100644 index 0000000000..238aef6e38 --- /dev/null +++ b/host/test/pathconf.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { PATHCONF_NAMES } from "../src/generated/abi"; +import { filesystemPathconf } from "../src/pathconf"; +import { DeviceFileSystem } from "../src/vfs/device-fs"; +import { HostFileSystem } from "../src/vfs/host-fs"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; +import { NodeTimeProvider } from "../src/vfs/time"; +import { VirtualPlatformIO } from "../src/vfs/vfs"; +import { + ENOENT, + O_CREAT, + O_RDONLY, + O_RDWR, + SFSError, +} from "../src/vfs/sharedfs-vendor"; +import type { StatResult } from "../src/types"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const repoRoot = join(dirname(fileURLToPath(import.meta.url)), "../.."); + +function memoryFileSystem(): MemoryFileSystem { + return MemoryFileSystem.create(new SharedArrayBuffer(2 * 1024 * 1024)); +} + +describe("pathconf capability values", () => { + const regularStat: StatResult = { + dev: 1, + ino: 1, + mode: 0o100644, + nlink: 1, + uid: 0, + gid: 0, + size: 0, + atimeMs: 0, + mtimeMs: 0, + ctimeMs: 0, + }; + const fifoStat = { ...regularStat, mode: 0o010644 }; + const directoryStat = { ...regularStat, mode: 0o040755 }; + const memoryProfile = { + supportsSymlinks: true, + timestampResolutionNs: 1_000_000, + }; + const opfsProfile = { + supportsSymlinks: false, + timestampResolutionNs: null, + }; + + it("keeps the generated name table complete and unique", () => { + expect(Object.keys(PATHCONF_NAMES)).toHaveLength(24); + expect(new Set(Object.values(PATHCONF_NAMES)).size).toBe(24); + expect(Math.min(...Object.values(PATHCONF_NAMES))).toBe(0); + expect(Math.max(...Object.values(PATHCONF_NAMES))).toBe(23); + }); + + it("reports enforced namespace limits and backend capabilities", () => { + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.NAME_MAX, memoryProfile), + ).toBe(255); + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.PATH_MAX, memoryProfile), + ).toBe(4096); + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.NO_TRUNC, memoryProfile), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.CHOWN_RESTRICTED, + opfsProfile, + ), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.POSIX2_SYMLINKS, + memoryProfile, + ), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.POSIX2_SYMLINKS, + opfsProfile, + ), + ).toBeNull(); + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.ASYNC_IO, memoryProfile), + ).toBe(1); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.TIMESTAMP_RESOLUTION, + memoryProfile, + ), + ).toBe(1_000_000); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.TIMESTAMP_RESOLUTION, + opfsProfile, + ), + ).toBeNull(); + }); + + it("distinguishes indeterminate values from invalid associations", () => { + expect( + filesystemPathconf(regularStat, PATHCONF_NAMES.LINK_MAX, memoryProfile), + ).toBeNull(); + expect( + filesystemPathconf( + regularStat, + PATHCONF_NAMES.FILESIZEBITS, + memoryProfile, + ), + ).toBeNull(); + expect(() => + filesystemPathconf(regularStat, PATHCONF_NAMES.PIPE_BUF, memoryProfile), + ).toThrow(/EINVAL/); + expect( + filesystemPathconf(fifoStat, PATHCONF_NAMES.PIPE_BUF, memoryProfile), + ).toBeNull(); + expect( + filesystemPathconf( + directoryStat, + PATHCONF_NAMES.PIPE_BUF, + memoryProfile, + ), + ).toBeNull(); + expect(() => filesystemPathconf(regularStat, 999, memoryProfile)).toThrow( + /EINVAL/, + ); + }); +}); + +describe("pathconf VFS routing", () => { + it("uses the longest-prefix mount for pathname queries", () => { + const root = memoryFileSystem(); + const mounted = memoryFileSystem(); + const rootQuery = vi.spyOn(root, "pathconf").mockReturnValue(111); + const mountedQuery = vi.spyOn(mounted, "pathconf").mockReturnValue(222); + const io = new VirtualPlatformIO( + [ + { mountPoint: "/mnt", backend: mounted }, + { mountPoint: "/", backend: root }, + ], + new NodeTimeProvider(), + ); + + expect(io.pathconf("/mnt/file", PATHCONF_NAMES.PATH_MAX)).toBe(222); + expect(mountedQuery).toHaveBeenCalledWith("/file", PATHCONF_NAMES.PATH_MAX); + expect(rootQuery).not.toHaveBeenCalled(); + }); + + it("keeps fpathconf on the open handle's backend after unlink", () => { + const root = memoryFileSystem(); + const mounted = memoryFileSystem(); + root.mkdir("/mnt", 0o755); + const io = new VirtualPlatformIO( + [ + { mountPoint: "/mnt", backend: mounted }, + { mountPoint: "/", backend: root }, + ], + new NodeTimeProvider(), + ); + const fd = io.open("/mnt/file", O_CREAT | O_RDWR, 0o644); + io.unlink("/mnt/file"); + + expect(io.fpathconf(fd, PATHCONF_NAMES.NAME_MAX)).toBe(255); + try { + io.pathconf("/mnt/file", PATHCONF_NAMES.NAME_MAX); + throw new Error("pathconf unexpectedly accepted an unlinked path"); + } catch (error) { + expect(error).toBeInstanceOf(SFSError); + expect((error as SFSError).code).toBe(ENOENT); + } + io.close(fd); + }); + + it("validates device paths and live device handles", () => { + const device = new DeviceFileSystem(); + expect(device.pathconf("/null", PATHCONF_NAMES.NAME_MAX)).toBe(255); + const fd = device.open("/null", O_RDONLY, 0); + expect(device.fpathconf(fd, PATHCONF_NAMES.CHOWN_RESTRICTED)).toBe(1); + device.close(fd); + expect(() => device.fpathconf(fd, PATHCONF_NAMES.NAME_MAX)).toThrow(/EBADF/); + }); +}); + +describe("HostFileSystem fpathconf", () => { + const roots: string[] = []; + + afterEach(() => { + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }); + }); + + it("uses the live descriptor after its pathname is unlinked", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-pathconf-")); + roots.push(root); + writeFileSync(join(root, "file"), "data"); + const fs = new HostFileSystem(root); + const fd = fs.open("/file", O_RDONLY, 0); + fs.unlink("/file"); + + expect(fs.fpathconf(fd, PATHCONF_NAMES.PATH_MAX)).toBe(4096); + expect(() => fs.pathconf("/file", PATHCONF_NAMES.PATH_MAX)).toThrow(/ENOENT/); + fs.close(fd); + }); +}); + +describe("pathconf guest ABI", () => { + it.each([".wasm", ".wasm64.wasm"])( + "preserves values, errno, and pointer safety (%s)", + async (suffix) => { + const result = await runCentralizedProgram({ + programPath: join(repoRoot, `examples/pathconf_test${suffix}`), + argv: ["pathconf-test"], + timeout: 15_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("PATHCONF_PASS"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/host/test/php-browser-kernel-owned-vfs.test.ts b/host/test/php-browser-kernel-owned-vfs.test.ts new file mode 100644 index 0000000000..554c23a0a7 --- /dev/null +++ b/host/test/php-browser-kernel-owned-vfs.test.ts @@ -0,0 +1,29 @@ +/** + * The browser PHPT page is a focused Vite entry rather than an ordinary host + * unit-test import. Pin its BrowserKernel contract at the source boundary so + * removal of the worker-owned VFS migration cannot silently return the page to + * the deleted memfs/init API before a slow browser inventory run notices. + */ +import { readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +const repoRoot = resolve(import.meta.dirname, "../.."); +const phpBrowserRunner = join( + repoRoot, + "apps/browser-demos/pages/php-test/main.ts", +); + +describe("PHP browser worker-owned VFS contract", () => { + it("boots from an image and stages scripts through the owning worker", () => { + const source = readFileSync(phpBrowserRunner, "utf8"); + + expect(source).toContain("initFromImage({"); + expect(source).toContain("spawnFromVfs("); + expect(source).toContain("readFileSnapshotFromVfs("); + expect(source).toContain("writeFileToVfs("); + expect(source).toContain("unlinkFileFromVfs("); + expect(source).not.toMatch(/\bmemfs\s*:/); + expect(source).not.toMatch(/\.init\s*\(/); + }); +}); diff --git a/host/test/php-phpt-harness.test.ts b/host/test/php-phpt-harness.test.ts new file mode 100644 index 0000000000..4b356897ae --- /dev/null +++ b/host/test/php-phpt-harness.test.ts @@ -0,0 +1,569 @@ +import { describe, expect, it } from "vitest"; +import { + compareExpectation, + probeLoadedExtensions, + runPhpt, + splitArgs, + testScript, + type PhpRunResult, + type PhpRunner, + type PhptTest, +} from "../../scripts/run-php-upstream-tests"; + +type ScriptKind = Parameters[0]["kind"]; + +function runResult(overrides: Partial = {}): PhpRunResult { + return { + exitCode: 0, + stdout: "", + stderr: "", + durationMs: 1, + ...overrides, + }; +} + +class ScriptedRunner implements PhpRunner { + readonly calls: ScriptKind[] = []; + readonly envByKind = new Map(); + readonly argvByKind = new Map(); + + constructor( + private readonly results: Partial< + Record + >, + ) {} + + loadExtensionIniArgs(): string[] { + return []; + } + + async runScript( + opts: Parameters[0], + ): Promise { + this.calls.push(opts.kind); + this.envByKind.set(opts.kind, opts.env); + this.argvByKind.set(opts.kind, opts.argv); + const configured = this.results[opts.kind]; + const result = Array.isArray(configured) ? configured.shift() : configured; + if (!result) throw new Error(`unexpected ${opts.kind} invocation`); + return result; + } + + async close(): Promise {} +} + +function phpt(sections: Record): PhptTest { + return { + path: "/php-src/tests/harness.phpt", + rel: "tests/harness.phpt", + sourceRoot: "/php-src", + sections: { + TEST: "harness semantics", + FILE: "", + EXPECT: "ok", + ...sections, + }, + }; +} + +describe("PHP PHPT verdict semantics", () => { + it("matches Kandelo EXPECTF placeholders with PHP's POSIX rules", () => { + const separator = phpt({ EXPECTF: "a%eb" }); + delete separator.sections.EXPECT; + expect(compareExpectation(separator, "a/b").ok).toBe(true); + expect(compareExpectation(separator, "a\\b").ok).toBe(false); + + const float = phpt({ EXPECTF: "%f" }); + delete float.sections.EXPECT; + expect(compareExpectation(float, "1.0").ok).toBe(true); + expect(compareExpectation(float, "1.").ok).toBe(false); + }); + + it("translates PCRE generic newlines inside EXPECTF raw regex", () => { + const test = phpt({ EXPECTF: "%rfoo\\R?bar%r" }); + delete test.sections.EXPECT; + + expect(compareExpectation(test, "foo\r\nbar").ok).toBe(true); + expect(compareExpectation(test, "fooRbar").ok).toBe(false); + }); + + it("preserves quoted empty ARGS and FILEEOF byte boundaries", () => { + expect(splitArgs(`one "" '' three\\ four`)).toEqual([ + "one", + "", + "", + "three four", + ]); + const eof = phpt({ FILEEOF: "payload\r\n\n" }); + delete eof.sections.FILE; + expect(testScript(eof)).toBe("payload"); + }); + + it("derives extension inventory from the guest PHP binary", async () => { + const runner = new ScriptedRunner({ + file: runResult({ + output: + "startup noise\n__KANDELO_PHP_EXTENSIONS__[\"Core\",\"date\",\"Zend OPcache\"]__KANDELO_PHP_EXTENSIONS_END__", + }), + }); + + const loaded = await probeLoadedExtensions( + runner, + "/php-src", + new Set(["opcache"]), + 1000, + ); + + expect([...loaded].sort()).toEqual(["core", "date", "opcache"]); + }); + + it("anchors EXPECTREGEX against the complete output", async () => { + const runner = new ScriptedRunner({ + file: runResult({ stdout: "prefix wanted suffix" }), + }); + const test = phpt({ EXPECTREGEX: "wanted" }); + delete test.sections.EXPECT; + + const result = await runPhpt(test, runner, new Set(), 1000); + + expect(result.status).toBe("fail"); + }); + + it("normalizes CRLF without erasing standalone carriage returns", () => { + expect(compareExpectation(phpt({}), "ok\r\n").ok).toBe(true); + expect(compareExpectation(phpt({ EXPECT: "left\rright" }), "left\nright").ok) + .toBe(false); + }); + + it("matches run-tests.php's section-specific request environment", async () => { + const runner = new ScriptedRunner({ + skipif: runResult(), + file: runResult({ stdout: "ok" }), + clean: runResult(), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "", CLEAN: "" }), + runner, + new Set(), + 1000, + ); + + expect(result.status).toBe("pass"); + const fileEnv = runner.envByKind.get("file") ?? []; + expect(fileEnv).toContain("REDIRECT_STATUS=1"); + expect(fileEnv).toContain("REQUEST_METHOD=GET"); + expect(fileEnv).toContain("PATH_TRANSLATED=/php-src/tests/harness.php"); + expect(fileEnv).toContain("SCRIPT_FILENAME=/php-src/tests/harness.php"); + for (const kind of ["skipif", "clean"] as const) { + const env = runner.envByKind.get(kind) ?? []; + expect(env.some((entry) => entry.startsWith("REQUEST_METHOD="))).toBe(false); + expect(env.some((entry) => entry.startsWith("SCRIPT_FILENAME="))).toBe(false); + expect(env.some((entry) => entry.startsWith("PATH_TRANSLATED="))).toBe(false); + } + }); + + it("matches run-tests.php precedence for --ENV-- request variables", async () => { + const runner = new ScriptedRunner({ + skipif: runResult(), + file: runResult({ stdout: "ok" }), + clean: runResult(), + }); + const env = [ + "REDIRECT_STATUS=from-env", + "QUERY_STRING=from-env", + "PATH_TRANSLATED=/env/path.php", + "SCRIPT_FILENAME=/env/script.php", + "REQUEST_METHOD=POST", + "CONTENT_TYPE=text/plain", + "CONTENT_LENGTH=4", + "HTTP_COOKIE=from-env", + "TZ=UTC", + ].join("\n"); + + const result = await runPhpt( + phpt({ ENV: env, SKIPIF: "", CLEAN: "" }), + runner, + new Set(), + 1000, + ); + + expect(result.status).toBe("pass"); + const skipEnv = runner.envByKind.get("skipif") ?? []; + expect(skipEnv).toEqual(expect.arrayContaining([ + "REDIRECT_STATUS=from-env", + "CONTENT_TYPE=text/plain", + "CONTENT_LENGTH=4", + "HTTP_COOKIE=from-env", + "TZ=UTC", + ])); + for (const name of [ + "QUERY_STRING", + "PATH_TRANSLATED", + "SCRIPT_FILENAME", + "REQUEST_METHOD", + ]) { + expect(skipEnv.some((entry) => entry.startsWith(`${name}=`))).toBe(false); + } + + const fileEnv = runner.envByKind.get("file") ?? []; + expect(fileEnv).toEqual(expect.arrayContaining([ + "REDIRECT_STATUS=1", + "QUERY_STRING=from-env", + "PATH_TRANSLATED=/env/path.php", + "SCRIPT_FILENAME=/env/script.php", + "REQUEST_METHOD=GET", + "TZ=UTC", + ])); + expect(fileEnv.some((entry) => entry.startsWith("CONTENT_TYPE="))).toBe(false); + expect(fileEnv.some((entry) => entry.startsWith("CONTENT_LENGTH="))).toBe(false); + expect(fileEnv.some((entry) => entry.startsWith("HTTP_COOKIE="))).toBe(false); + + const cleanEnv = runner.envByKind.get("clean") ?? []; + expect(cleanEnv).toEqual(expect.arrayContaining([ + "REDIRECT_STATUS=1", + "TZ=UTC", + ])); + for (const name of [ + "QUERY_STRING", + "PATH_TRANSLATED", + "SCRIPT_FILENAME", + "REQUEST_METHOD", + "CONTENT_TYPE", + "CONTENT_LENGTH", + "HTTP_COOKIE", + ]) { + expect(cleanEnv.some((entry) => entry.startsWith(`${name}=`))).toBe(false); + } + }); + + it("pins the complete run-tests.php baseline INI", async () => { + const runner = new ScriptedRunner({ file: runResult({ stdout: "ok" }) }); + + const result = await runPhpt(phpt({}), runner, new Set(), 1000); + + expect(result.status).toBe("pass"); + const argv = runner.argvByKind.get("file") ?? []; + expect(argv).toContain("zend.exception_string_param_max_len=15"); + expect(argv).toContain("short_open_tag=0"); + }); + + it.each([ + [ + { CAPTURE_STDIO: "STDOUT" }, + "partial CAPTURE_STDIO requires per-descriptor inheritance", + ], + [ + { EXTENSIONS: "opcache" }, + "opcache SHM mode requires unsupported cross-process MAP_SHARED", + ], + ] as Array<[Record, string]>)( + "reports an explicit platform boundary for %j", + async (sections, reason) => { + const runner = new ScriptedRunner({}); + + const result = await runPhpt(phpt(sections), runner, new Set(), 1000); + + expect(result.status).toBe("unsupported"); + expect(result.reason).toContain(reason); + expect(runner.calls).toEqual([]); + }, + ); + + it("allows manually loaded opcache when CLI caching stays disabled", async () => { + const runner = new ScriptedRunner({ + file: runResult({ stdout: "ok" }), + }); + const result = await runPhpt( + phpt({ + FILE: ``, + }), + runner, + new Set(["opcache"]), + 1000, + ); + + expect(result.status).toBe("pass"); + expect(runner.calls).toEqual(["file"]); + }); + + it("classifies manually activated opcache SHM mode as unsupported", async () => { + const runner = new ScriptedRunner({}); + const result = await runPhpt( + phpt({ + FILE: ``, + }), + runner, + new Set(["opcache"]), + 1000, + ); + + expect(result.status).toBe("unsupported"); + expect(result.reason).toContain( + "opcache SHM mode requires unsupported cross-process MAP_SHARED", + ); + expect(runner.calls).toEqual([]); + }); + + it("allows manually loaded opcache in explicit file-cache-only mode", async () => { + const runner = new ScriptedRunner({ + file: runResult({ stdout: "ok" }), + }); + const result = await runPhpt( + phpt({ + FILE: ``, + }), + runner, + new Set(["opcache"]), + 1000, + ); + + expect(result.status).toBe("pass"); + expect(runner.calls).toEqual(["file"]); + }); + + it("reports PHPDBG unsupported even when the standard phpdbg variable is set", async () => { + const previous = process.env.TEST_PHPDBG_EXECUTABLE; + process.env.TEST_PHPDBG_EXECUTABLE = "/usr/local/bin/phpdbg"; + try { + const runner = new ScriptedRunner({}); + const result = await runPhpt( + phpt({ PHPDBG: "run\nquit" }), + runner, + new Set(), + 1000, + ); + + expect(result.status).toBe("unsupported"); + expect(result.reason).toContain("phpdbg SAPI"); + expect(runner.calls).toEqual([]); + } finally { + if (previous === undefined) { + delete process.env.TEST_PHPDBG_EXECUTABLE; + } else { + process.env.TEST_PHPDBG_EXECUTABLE = previous; + } + } + }); + + it("runs FILE after SKIPIF synthesizes XFAIL", async () => { + const runner = new ScriptedRunner({ + skipif: runResult({ stdout: "xfail known upstream defect" }), + file: runResult({ stdout: "wrong" }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["skipif", "file"]); + expect(result.status).toBe("xfail"); + expect(result.reason).toBe("known upstream defect"); + }); + + it("borks non-directive SKIPIF output without running FILE", async () => { + const runner = new ScriptedRunner({ + skipif: runResult({ stdout: "unexpected diagnostic" }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["skipif"]); + expect(result.status).toBe("bork"); + expect(result.reason).toBe("invalid output from SKIPIF"); + }); + + it("uses stdout for SKIPIF verdicts and keeps stderr diagnostic-only", async () => { + const runner = new ScriptedRunner({ + skipif: runResult({ + stdout: "skip lsof(8) not available", + stderr: "sh: lsof: not found", + output: "sh: lsof: not found\nskip lsof(8) not available", + }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["skipif"]); + expect(result.status).toBe("skip"); + expect(result.reason).toBe("skip lsof(8) not available"); + }); + + it("honors a stdout nocache directive despite interleaved stderr", async () => { + const runner = new ScriptedRunner({ + skipif: runResult({ + stdout: "nocache", + stderr: "diagnostic", + output: "diagnostic\nnocache", + }), + file: runResult({ stdout: "ok" }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["skipif", "file"]); + expect(result.status).toBe("pass"); + }); + + it.each(["info context", "flaky timing", "xleak known leak"])( + "accepts the upstream SKIPIF directive %s", + async (directive) => { + const runner = new ScriptedRunner({ + skipif: runResult({ stdout: directive }), + file: runResult({ stdout: "ok" }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: `` }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["skipif", "file"]); + expect(result.status).toBe("pass"); + }, + ); + + it("reports WARN after a SKIPIF warning even when FILE matches", async () => { + const runner = new ScriptedRunner({ + skipif: runResult({ stdout: "warn constrained environment" }), + file: runResult({ stdout: "ok" }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["skipif", "file"]); + expect(result.status).toBe("warn"); + expect(result.reason).toContain("constrained environment"); + }); + + it("reports FAIL with warning context when warned SKIPIF precedes a mismatch", async () => { + const runner = new ScriptedRunner({ + skipif: runResult({ stdout: "warn constrained environment" }), + file: runResult({ stdout: "wrong" }), + }); + + const result = await runPhpt( + phpt({ SKIPIF: "" }), + runner, + new Set(), + 1000, + ); + + expect(result.status).toBe("fail"); + expect(result.reason).toContain("constrained environment"); + expect(result.detail).toBeDefined(); + }); + + it("retries a flaky PHPT once and reports a retry-pass as WARN", async () => { + const runner = new ScriptedRunner({ + file: [ + runResult({ stdout: "transient mismatch" }), + runResult({ stdout: "ok" }), + ], + }); + + const result = await runPhpt( + phpt({ FLAKY: "intermittent timing" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["file", "file"]); + expect(result.status).toBe("warn"); + expect(result.reason).toBe("test passed on retry attempt"); + }); + + it("does not retry flaky infrastructure errors", async () => { + const runner = new ScriptedRunner({ + file: runResult({ exitCode: -1, error: "worker crashed" }), + }); + + const result = await runPhpt( + phpt({ FLAKY: "intermittent timing" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["file"]); + expect(result.status).toBe("fail"); + }); + + it("borks output from CLEAN when the test otherwise passes", async () => { + const runner = new ScriptedRunner({ + file: runResult({ stdout: "ok" }), + clean: runResult({ stdout: "unexpected cleanup output" }), + }); + + const result = await runPhpt( + phpt({ CLEAN: "" }), + runner, + new Set(), + 1000, + ); + + expect(runner.calls).toEqual(["file", "clean"]); + expect(result.status).toBe("bork"); + expect(result.detail).toContain("invalid output from CLEAN"); + }); + + it.each([ + ["TIMEOUT", "time"], + ["worker crashed", "fail"], + ] as const)( + "does not hide FILE infrastructure error %s behind XFAIL", + async (error, expectedStatus) => { + const runner = new ScriptedRunner({ + file: runResult({ exitCode: -1, error }), + }); + + const result = await runPhpt( + phpt({ XFAIL: "known output mismatch" }), + runner, + new Set(), + 1000, + ); + + expect(result.status).toBe(expectedStatus); + }, + ); +}); diff --git a/host/test/php-test-fixtures.test.ts b/host/test/php-test-fixtures.test.ts new file mode 100644 index 0000000000..8af176abad --- /dev/null +++ b/host/test/php-test-fixtures.test.ts @@ -0,0 +1,85 @@ +import { + mkdirSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { preparePhpTestFixtures } from "../../images/vfs/scripts/php-test-fixtures"; + +describe("preparePhpTestFixtures", () => { + it("patches and re-signs the wasm32 no-phar fixture exactly once", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-php-fixtures-phar-test-")); + try { + const sourceRoot = join(root, "source"); + const fixtureDir = join(sourceRoot, "ext/phar/tests/files"); + const fixture = join(fixtureDir, "nophar.phar"); + mkdirSync(fixtureDir, { recursive: true }); + + const payload = Buffer.from( + "prefix 0xffffffff middle 0xffffffff suffix", + "ascii", + ); + const trailer = Buffer.alloc(28); + createHash("sha1").update(payload).digest().copy(trailer, 0); + trailer.writeUInt32LE(2, 20); + trailer.write("GBMB", 24, "ascii"); + writeFileSync(fixture, Buffer.concat([payload, trailer])); + + preparePhpTestFixtures(sourceRoot, join(root, "missing-fixtures")); + const once = readFileSync(fixture); + expect(once.length).toBe(payload.length + trailer.length); + expect(once.toString("ascii", 0, payload.length)).toBe( + "prefix (-1) middle (-1) suffix", + ); + expect(once.readUInt32LE(once.length - 8)).toBe(2); + expect(once.subarray(once.length - 4).toString("ascii")).toBe("GBMB"); + expect(once.subarray(once.length - 28, once.length - 8)).toEqual( + createHash("sha1") + .update(once.subarray(0, once.length - 28)) + .digest(), + ); + + preparePhpTestFixtures(sourceRoot, join(root, "missing-fixtures")); + expect(readFileSync(fixture)).toEqual(once); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it("applies non-OpenSSL maintenance once when SNI fixtures are absent", () => { + const root = mkdtempSync(join(tmpdir(), "kandelo-php-fixtures-test-")); + try { + const mysqliDir = join(root, "source/ext/mysqli/tests"); + const fakeServer = join(mysqliDir, "fake_server.inc"); + mkdirSync(mysqliDir, { recursive: true }); + writeFileSync( + fakeServer, + ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`, + ); + + const sourceRoot = join(root, "source"); + const missingFixtureRoot = join(root, "missing-fixtures"); + preparePhpTestFixtures(sourceRoot, missingFixtureRoot); + const once = readFileSync(fakeServer, "utf8"); + expect(once).toContain("MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS"); + + preparePhpTestFixtures(sourceRoot, missingFixtureRoot); + expect(readFileSync(fakeServer, "utf8")).toBe(once); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/php-test-lazy-assets.test.ts b/host/test/php-test-lazy-assets.test.ts new file mode 100644 index 0000000000..9fb77da3c0 --- /dev/null +++ b/host/test/php-test-lazy-assets.test.ts @@ -0,0 +1,34 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, it } from "vitest"; + +import { rewriteRootfsLazyFileUrls } from "../../apps/browser-demos/lib/init/rootfs-lazy-files"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const repoRoot = resolve(import.meta.dirname, "../.."); +const rootfsImage = join(repoRoot, "host/wasm/rootfs.vfs"); + +describe.skipIf(!existsSync(rootfsImage))("PHP browser PHPT lazy assets", () => { + it("rewrites every canonical rootfs executable URL, including dash, ps, and pgrep", () => { + const fs = MemoryFileSystem.fromImage( + new Uint8Array(readFileSync(rootfsImage)), + ); + const before = fs.exportLazyEntries(); + const expected = new Map([ + ["/usr/bin/dash", "binaries/programs/wasm32/dash.wasm"], + ["/usr/bin/ps", "binaries/programs/wasm32/posix-utils-lite/ps.wasm"], + ["/usr/bin/pgrep", "binaries/programs/wasm32/posix-utils-lite/pgrep.wasm"], + ]); + + for (const [path, url] of expected) { + expect(before.find((entry) => entry.path === path)?.url).toBe(url); + } + + rewriteRootfsLazyFileUrls(fs); + const after = fs.exportLazyEntries(); + expect(after.filter((entry) => entry.url.startsWith("binaries/"))).toEqual([]); + for (const [path, sourceUrl] of expected) { + expect(after.find((entry) => entry.path === path)?.url).not.toBe(sourceUrl); + } + }); +}); diff --git a/host/test/platform-node.test.ts b/host/test/platform-node.test.ts index ed3b4fe566..bc20b6223f 100644 --- a/host/test/platform-node.test.ts +++ b/host/test/platform-node.test.ts @@ -3,8 +3,16 @@ * translation that bridges the kernel's POSIX namespace to Node `fs.*`. */ +import { + linkSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; import { describe, it, expect } from "vitest"; -import { translateWindowsDrivePath } from "../src/platform/node"; +import { NodePlatformIO, translateWindowsDrivePath } from "../src/platform/node"; describe("translateWindowsDrivePath", () => { it("converts /C/foo → C:/foo", () => { @@ -45,3 +53,57 @@ describe("translateWindowsDrivePath", () => { expect(translateWindowsDrivePath("/")).toBeNull(); }); }); + +describe("NodePlatformIO file identity", () => { + it("preserves hard-link aliases and distinguishes another inode", () => { + const dir = mkdtempSync(join(tmpdir(), "kandelo-file-identity-")); + try { + const original = join(dir, "original"); + const alias = join(dir, "alias"); + const other = join(dir, "other"); + writeFileSync(original, "one"); + linkSync(original, alias); + writeFileSync(other, "two"); + + const io = new NodePlatformIO(); + const originalStat = io.stat(original); + const aliasStat = io.stat(alias); + const otherStat = io.stat(other); + const originalIdentity = io.fileIdentity( + original, + BigInt(originalStat.dev), + BigInt(originalStat.ino), + ); + + expect(io.fileIdentity( + alias, + BigInt(aliasStat.dev), + BigInt(aliasStat.ino), + )).toBe(originalIdentity); + expect(io.fileIdentity( + other, + BigInt(otherStat.dev), + BigInt(otherStat.ino), + )).not.toBe(originalIdentity); + expect(io.fileIdentity(original, 0n, 0n)).toBeNull(); + + const handle = io.open(original, 2, 0); + const handleStat = io.fstat(handle); + const handleIdentity = io.fileHandleIdentity( + handle, + BigInt(handleStat.dev), + BigInt(handleStat.ino), + ); + expect(handleIdentity).not.toBeNull(); + io.unlink(original); + expect(io.fileHandleIdentity( + handle, + BigInt(handleStat.dev), + BigInt(handleStat.ino), + )).toBe(handleIdentity); + io.close(handle); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/host/test/posix-timer-thread.test.ts b/host/test/posix-timer-thread.test.ts new file mode 100644 index 0000000000..ca574c3848 --- /dev/null +++ b/host/test/posix-timer-thread.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const timerThreadBinary = tryResolveBinary("programs/posix-timer-thread.wasm"); +const timerThreadBinary64 = tryResolveBinary( + "programs/wasm64/posix-timer-thread.wasm", +); + +async function expectTimerThreadPass(programPath: string): Promise { + const result = await runCentralizedProgram({ + programPath, + argv: ["posix-timer-thread"], + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0); + expect(result.stderr).toBe(""); + expect(result.stdout).toContain("CALLBACKS: first=1/41 second=1/42"); + expect(result.stdout).toMatch( + /SIGNAL_TIMER: code=-2 value=77 timer=\d+ overrun=[1-9]\d* reset=0/, + ); + expect(result.stdout).toMatch(/WAIT_DEADLINE: delivered=\d+ms timeout=\d+ms/); + expect(result.stdout).toContain("HELPER_CHURN: 24"); + expect(result.stdout).toContain("PASS"); +} + +describe("POSIX SIGEV_THREAD timers", () => { + it.skipIf(!timerThreadBinary)( + "delivers concurrent timer expirations to musl's dedicated helper threads", + async () => { + await expectTimerThreadPass(timerThreadBinary!); + }, + 15_000, + ); + + it.skipIf(!timerThreadBinary64)( + "preserves helper callbacks through the fixed-width wasm64 timer wire", + async () => { + await expectTimerThreadPass(timerThreadBinary64!); + }, + 15_000, + ); +}); diff --git a/host/test/process-wait-lifecycle.test.ts b/host/test/process-wait-lifecycle.test.ts index 741634cba3..f945412780 100644 --- a/host/test/process-wait-lifecycle.test.ts +++ b/host/test/process-wait-lifecycle.test.ts @@ -1,45 +1,100 @@ import { describe, expect, it, vi } from "vitest"; -import { ABI_SYSCALLS } from "../src/generated/abi"; +import { + ABI_SYSCALLS, + CH_DATA, + CH_RETURN, + CH_SIG_FLAGS, + CH_SIG_SIGNUM, + CH_STATUS, + CHANNEL_STATUS_COMPLETE, + CHANNEL_STATUS_PENDING, + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, + PROCESS_STATE_RUNNING, + PROCESS_STATE_STOPPED, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + WAIT_CLD_EXITED, + WAIT_CLD_STOPPED, + WAIT_EVENT_EXITED, + WAIT_EVENT_STOPPED, + WAIT_WEXITED, + WAIT_WNOHANG, + WAIT_WNOWAIT, + WAIT_WSTOPPED, + WAKE_PROCESS_CONTINUED, + WAKE_PROCESS_STOPPED, +} from "../src/generated/abi"; import { CentralizedKernelWorker } from "../src/kernel-worker"; const SIGCHLD = 17; -const WNOHANG = 1; +const SIGCONT = 18; +const SIGTERM = 15; +const SIGUSR1 = 10; +const SA_RESTART = 0x10000000; describe("Rust-owned process wait lifecycle", () => { - it("wait4 consumes Rust-selected zombies and writes the Rust wait status", () => { + it("wait4 atomically consumes a Rust-selected event and copies status+rusage", () => { const kernelMemory = createSharedMemory(); const processMemory = createSharedMemory(); const statusPtr = 256; const waitStatus = 5 << 8; - const wait4Poll = vi.fn((_parentPid: number, _targetPid: number, statusPtr: number | bigint) => { - new DataView(kernelMemory.buffer).setInt32(Number(statusPtr), waitStatus, true); + const rusage = Uint8Array.from( + { length: STRUCT_SIZE_WASM_RUSAGE_WIRE }, + (_, index) => index & 0xff, + ); + const waitChildPoll = vi.fn(( + _parentPid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus, + siCode: 1, + siStatus: 5, + childUid: 123, + rusage, + }); return 42; }); const reapExitedChild = vi.fn(() => 0); const worker = createWorkerHarness({ - kernel_wait4_poll: wait4Poll, + kernel_wait_child_poll: waitChildPoll, kernel_reap_exited_child: reapExitedChild, }); worker.kernelMemory = kernelMemory; worker.scratchOffset = 128; worker.completeWaitpid = vi.fn(); - worker.handleWaitpid(createChannel(7, processMemory), [-1, statusPtr, 0, 0]); + const rusagePtr = 512; + worker.handleWaitpid( + createChannel(7, processMemory), + [-1, statusPtr, 0, rusagePtr], + ); - expect(wait4Poll).toHaveBeenCalledWith(7, -1, 128); - expect(reapExitedChild).toHaveBeenCalledWith(7, 42); + expect(waitChildPoll).toHaveBeenCalledWith(7, -1, WAIT_EVENT_EXITED, 0, 128); + expect(reapExitedChild).not.toHaveBeenCalled(); expect(new DataView(processMemory.buffer).getInt32(statusPtr, true)).toBe(waitStatus); + expect(new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + )).toEqual(rusage); expect(worker.completeWaitpid).toHaveBeenCalledWith( expect.any(Object), - [-1, statusPtr, 0, 0], + [-1, statusPtr, 0, rusagePtr], 42, 0, ); }); it("wait4 leaves blocking waits in the host queue when Rust reports a running child", () => { - const wait4Poll = vi.fn(() => 0); - const worker = createWorkerHarness({ kernel_wait4_poll: wait4Poll }); + const waitChildPoll = vi.fn(() => 0); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); worker.kernelMemory = createSharedMemory(); worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); @@ -60,42 +115,1148 @@ describe("Rust-owned process wait lifecycle", () => { ]); }); - it("wait4 WNOHANG completes without queuing when Rust reports a running child", () => { - const worker = createWorkerHarness({ kernel_wait4_poll: vi.fn(() => 0) }); + it("honors cancellation that lands before a wait can enqueue", () => { + const waitChildPoll = vi.fn(() => 0); + const processMemory = createSharedMemory(); + const channel = createChannel(7, processMemory); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.pendingCancels = new Set([channel]); + worker.waitingForChild = []; + worker.completeChannelRaw = vi.fn(); + worker.relistenChannel = vi.fn(); + + worker.handleWaitpid(channel, [-1, 0, 0, 0]); + + expect(waitChildPoll).not.toHaveBeenCalled(); + expect(worker.waitingForChild).toEqual([]); + expect(worker.completeChannelRaw).toHaveBeenCalledWith(channel, -4, 4); + expect(worker.relistenChannel).toHaveBeenCalledWith(channel); + }); + + it("wait4 WNOHANG completes without queuing when Rust reports no event", () => { + const worker = createWorkerHarness({ kernel_wait_child_poll: vi.fn(() => 0) }); worker.kernelMemory = createSharedMemory(); worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); - worker.handleWaitpid(createChannel(7, createSharedMemory()), [-1, 0, WNOHANG, 0]); + worker.handleWaitpid(createChannel(7, createSharedMemory()), [-1, 0, WAIT_WNOHANG, 0]); expect(worker.waitingForChild).toEqual([]); expect(worker.completeWaitpid).toHaveBeenCalledWith( expect.any(Object), - [-1, 0, WNOHANG, 0], + [-1, 0, WAIT_WNOHANG, 0], 0, 0, ); }); it("wait4 passes a bigint status pointer for wasm64 kernels", () => { - const wait4Poll = vi.fn(() => 0); - const worker = createWorkerHarness({ kernel_wait4_poll: wait4Poll }, 8); + const waitChildPoll = vi.fn(() => 0); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }, 8); worker.kernelMemory = createSharedMemory(); worker.waitingForChild = []; worker.completeWaitpid = vi.fn(); - worker.handleWaitpid(createChannel(7, createSharedMemory()), [-1, 0, WNOHANG, 0]); + worker.handleWaitpid(createChannel(7, createSharedMemory()), [-1, 0, WAIT_WNOHANG, 0]); - expect(wait4Poll).toHaveBeenCalledWith(7, -1, BigInt(128)); + expect(waitChildPoll).toHaveBeenCalledWith( + 7, + -1, + WAIT_EVENT_EXITED, + 0, + BigInt(128), + ); expect(worker.waitingForChild).toEqual([]); expect(worker.completeWaitpid).toHaveBeenCalledWith( expect.any(Object), - [-1, 0, WNOHANG, 0], + [-1, 0, WAIT_WNOHANG, 0], 0, 0, ); }); + it("returns EFAULT before polling or consuming an event for invalid wait4 outputs", () => { + const waitChildPoll = vi.fn(() => 42); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.completeWaitpid = vi.fn(); + const processMemory = createSharedMemory(); + const invalidStatusPtr = processMemory.buffer.byteLength - 2; + const args = [-1, invalidStatusPtr, 0, 0]; + + worker.handleWaitpid(createChannel(7, processMemory), args); + + expect(waitChildPoll).not.toHaveBeenCalled(); + expect(worker.completeWaitpid).toHaveBeenCalledWith( + expect.any(Object), + args, + -1, + 14, + ); + }); + + it("waitid passes STOPPED+WNOWAIT and writes exact CLD, uid, status, and rusage", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const siginfoPtr = 512; + const rusagePtr = 1024; + const rusage = new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x5a); + const waitChildPoll = vi.fn(( + _parentPid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: (19 << 8) | 0x7f, + siCode: WAIT_CLD_STOPPED, + siStatus: 19, + childUid: 4242, + rusage, + }); + return 44; + }); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.kernelMemory = kernelMemory; + worker.completeWaitid = vi.fn(); + const args = [1, 44, siginfoPtr, WAIT_WSTOPPED | WAIT_WNOWAIT, rusagePtr]; + + worker.handleWaitid(createChannel(7, processMemory), args); + + expect(waitChildPoll).toHaveBeenCalledWith( + 7, + 44, + WAIT_EVENT_STOPPED, + WAIT_WNOWAIT, + 128, + ); + const siginfo = new DataView(processMemory.buffer); + expect(siginfo.getInt32(siginfoPtr, true)).toBe(SIGCHLD); + expect(siginfo.getInt32(siginfoPtr + 8, true)).toBe(WAIT_CLD_STOPPED); + expect(siginfo.getInt32(siginfoPtr + 12, true)).toBe(44); + expect(siginfo.getUint32(siginfoPtr + 16, true)).toBe(4242); + expect(siginfo.getInt32(siginfoPtr + 20, true)).toBe(19); + expect(new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + )).toEqual(rusage); + expect(worker.completeWaitid).toHaveBeenCalledWith( + expect.any(Object), + args, + 0, + 0, + ); + }); + + it("waitid writes musl's aligned wasm64 siginfo fields", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel = createChannel(7, processMemory); + const siginfoPtr = 512; + const waitChildPoll = vi.fn(( + _parentPid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number | bigint, + ) => { + writeKernelWaitResult(kernelMemory, Number(resultPtr), { + waitStatus: 9 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 9, + childUid: 5150, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 44; + }); + const worker = createWorkerHarness( + { kernel_wait_child_poll: waitChildPoll }, + 8, + ); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + ptrWidth: 8, + }]]); + worker.completeWaitid = vi.fn(); + const args = [1, 44, siginfoPtr, WAIT_WEXITED, 0]; + + worker.handleWaitid(channel, args); + + const siginfo = new DataView(processMemory.buffer); + expect(siginfo.getInt32(siginfoPtr, true)).toBe(SIGCHLD); + expect(siginfo.getInt32(siginfoPtr + 8, true)).toBe(WAIT_CLD_EXITED); + expect(siginfo.getUint32(siginfoPtr + 12, true)).toBe(0); + expect(siginfo.getInt32(siginfoPtr + 16, true)).toBe(44); + expect(siginfo.getUint32(siginfoPtr + 20, true)).toBe(5150); + expect(siginfo.getInt32(siginfoPtr + 24, true)).toBe(9); + }); + + it("waitid WNOHANG zeros all siginfo bytes and leaves rusage untouched", () => { + const processMemory = createSharedMemory(); + const siginfoPtr = 512; + const rusagePtr = 1024; + new Uint8Array(processMemory.buffer, siginfoPtr, 128).fill(0xa5); + new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + ).fill(0x6b); + const waitChildPoll = vi.fn(() => 0); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.completeWaitid = vi.fn(); + const args = [0, 0, siginfoPtr, WAIT_WEXITED | WAIT_WNOHANG, rusagePtr]; + + worker.handleWaitid(createChannel(7, processMemory), args); + + expect(new Uint8Array(processMemory.buffer, siginfoPtr, 128)) + .toEqual(new Uint8Array(128)); + expect(new Uint8Array( + processMemory.buffer, + rusagePtr, + STRUCT_SIZE_WASM_RUSAGE_WIRE, + )).toEqual(new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE).fill(0x6b)); + expect(worker.completeWaitid).toHaveBeenCalledWith( + expect.any(Object), + args, + 0, + 0, + ); + }); + + it("rejects invalid waitid idtypes and required null siginfo before polling", () => { + const waitChildPoll = vi.fn(() => 0); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.completeWaitid = vi.fn(); + const channel = createChannel(7, createSharedMemory()); + + worker.handleWaitid(channel, [99, 0, 512, WAIT_WEXITED, 0]); + worker.handleWaitid(channel, [0, 0, 0, WAIT_WEXITED, 0]); + + expect(waitChildPoll).not.toHaveBeenCalled(); + expect(worker.completeWaitid.mock.calls.map((call: unknown[]) => call[3])) + .toEqual([22, 14]); + }); + + it("owns a drained wake batch before nested SIGCHLD work reuses scratch", () => { + const kernelMemory = createSharedMemory(); + const drain = vi.fn((outPtr: number, _outLen: number, _max: number) => { + writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_STOPPED); + writeWakeEvent(kernelMemory, outPtr, 1, 43, WAKE_PROCESS_CONTINUED); + return 2; + }); + const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); + worker.kernelMemory = kernelMemory; + worker.stoppedPids = new Set(); + worker.notifyParentOfChildStateTransition = vi.fn(() => { + new Uint8Array(kernelMemory.buffer).fill(0xff); + }); + worker.resumeStoppedProcess = vi.fn(() => true); + + worker.drainAndProcessWakeupEvents(); + + expect(worker.stoppedPids.has(42)).toBe(true); + expect(worker.resumeStoppedProcess).toHaveBeenCalledWith(43); + expect(worker.notifyParentOfChildStateTransition).toHaveBeenCalledTimes(2); + }); + + it("does not report CONTINUED when resume preflight stops the process again", () => { + const kernelMemory = createSharedMemory(); + let drained = false; + const drain = vi.fn((outPtr: number) => { + if (drained) return 0; + drained = true; + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 43, + WAKE_PROCESS_CONTINUED, + ); + return 1; + }); + const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); + worker.kernelMemory = kernelMemory; + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + worker.resumeStoppedProcess = vi.fn(() => false); + worker.notifyParentOfChildStateTransition = vi.fn(); + worker.anyPendingRetryNeedsSignalSafeWake = vi.fn(() => false); + worker.scheduleWakeBlockedRetries = vi.fn(); + + worker.drainAndProcessWakeupEvents(); + + expect(worker.resumeStoppedProcess).toHaveBeenCalledWith(43); + expect(worker.notifyParentOfChildStateTransition).not.toHaveBeenCalled(); + }); + + it("drains a STOPPED transition generated while CONTINUED preflight fails", () => { + const kernelMemory = createSharedMemory(); + let batch = 0; + const drain = vi.fn((outPtr: number) => { + if (batch++ === 0) { + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 43, + WAKE_PROCESS_CONTINUED, + ); + return 1; + } + if (batch === 2) { + writeWakeEvent( + kernelMemory, + outPtr, + 0, + 43, + WAKE_PROCESS_STOPPED, + ); + return 1; + } + return 0; + }); + const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); + worker.kernelMemory = kernelMemory; + worker.stoppedPids = new Set(); + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + worker.resumeStoppedProcess = vi.fn(() => false); + worker.notifyParentOfChildStateTransition = vi.fn(); + worker.anyPendingRetryNeedsSignalSafeWake = vi.fn(() => false); + worker.scheduleWakeBlockedRetries = vi.fn(); + + worker.drainAndProcessWakeupEvents(); + + expect(drain).toHaveBeenCalledTimes(2); + expect(worker.stoppedPids.has(43)).toBe(true); + expect(worker.notifyParentOfChildStateTransition).toHaveBeenCalledOnce(); + expect(worker.notifyParentOfChildStateTransition).toHaveBeenCalledWith(43); + }); + + it("drains overflow wake batches until a short batch includes lifecycle events", () => { + const kernelMemory = createSharedMemory(); + let batch = 0; + const drain = vi.fn((outPtr: number) => { + if (batch++ === 0) { + for (let i = 0; i < 256; i++) { + writeWakeEvent(kernelMemory, outPtr, i, i + 100, 1); + } + return 256; + } + writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_STOPPED); + return 1; + }); + const worker = createWorkerHarness({ kernel_drain_wakeup_events: drain }); + worker.kernelMemory = kernelMemory; + worker.stoppedPids = new Set(); + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + worker.notifyParentOfChildStateTransition = vi.fn(); + worker.anyPendingRetryNeedsSignalSafeWake = vi.fn(() => false); + worker.scheduleWakeBlockedRetries = vi.fn(); + + worker.drainAndProcessWakeupEvents(); + + expect(drain).toHaveBeenCalledTimes(2); + expect(worker.stoppedPids.has(42)).toBe(true); + }); + + it("finalizes signal death before a stale continue event can notify or reap", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel = createChannel(42, processMemory); + const drain = vi.fn((outPtr: number) => { + writeWakeEvent(kernelMemory, outPtr, 0, 42, WAKE_PROCESS_CONTINUED); + return 1; + }); + let exitSignal = SIGTERM; + const onExit = vi.fn(); + const worker = createWorkerHarness({ + kernel_drain_wakeup_events: drain, + kernel_get_process_state: vi.fn(() => 2), + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[42, { + channels: [channel], + memory: processMemory, + }]]); + worker.hostReaped = new Set(); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.deferredProcessWorkerStarts = new Map(); + worker.pendingSleeps = new Map(); + worker.releaseAllSharedMemoryForProcess = vi.fn(); + worker.notifyParentOfExitedProcess = vi.fn(() => { exitSignal = -3; }); + worker.resumeStoppedProcess = vi.fn(); + worker.notifyParentOfChildStateTransition = vi.fn(); + worker.callbacks = { onExit }; + + worker.drainAndProcessWakeupEvents(); + + expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledWith(42); + expect(onExit).toHaveBeenCalledWith(42, 128 + SIGTERM); + expect(worker.resumeStoppedProcess).not.toHaveBeenCalled(); + expect(worker.notifyParentOfChildStateTransition).not.toHaveBeenCalled(); + }); + + it("wakes a matching parent waiter while SA_NOCLDSTOP suppresses only SIGCHLD", () => { + const worker = createWorkerHarness({ + kernel_get_parent_pid: vi.fn(() => 7), + kernel_has_sa_nocldstop: vi.fn(() => 1), + }); + worker.sendSignalToProcess = vi.fn(); + worker.wakeWaitingParent = vi.fn(); + + worker.notifyParentOfChildStateTransition(42); + + expect(worker.sendSignalToProcess).not.toHaveBeenCalled(); + expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); + }); + + it("uses WNOWAIT for process-group waiter rechecks", () => { + const waitChildPoll = vi.fn(() => 0); + const processMemory = createSharedMemory(); + const channel = createChannel(7, processMemory); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.processes = new Map([[7, { channels: [channel], memory: processMemory }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [0, 0, 0, 0], + pid: 0, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + + worker.recheckDeferredWaitpids(); + + expect(waitChildPoll).toHaveBeenCalledWith( + 7, + 0, + WAIT_EVENT_EXITED, + WAIT_WNOWAIT, + 128, + ); + }); + + it("services status that becomes eligible after a process-group change", () => { + const channel = createChannel(7, createSharedMemory()); + const worker = createWorkerHarness({}); + worker.processes = new Map([[7, { + channels: [channel], + memory: channel.memory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [0, 0, 0, 0], + pid: 0, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + worker.pollWaitableChild = vi.fn(() => ({ + kind: "event", + childPid: 42, + waitStatus: 0, + siCode: WAIT_CLD_EXITED, + siStatus: 0, + childUid: 0, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + })); + worker.wakeWaitingParent = vi.fn(); + + worker.recheckDeferredWaitpids(); + + expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); + expect(worker.waitingForChild).toHaveLength(1); + }); + + it("completes a consuming waiter and a following ECHILD waiter in one wake", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const first = createChannel(7, processMemory, 0); + const second = createChannel(7, processMemory, 256); + let pollCount = 0; + const waitChildPoll = vi.fn(( + _parentPid: number, + _targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + if (pollCount++ > 0) return -10; // ECHILD after the first wait reaps. + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 3 << 8, + siCode: WAIT_CLD_EXITED, + siStatus: 3, + childUid: 12, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[7, { + channels: [first, second], + memory: processMemory, + }]]); + worker.completeWaitpid = vi.fn(); + worker.waitingForChild = [ + { + parentPid: 7, + channel: first, + origArgs: [42, 1024, 0, 0], + pid: 42, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + { + parentPid: 7, + channel: second, + origArgs: [42, 1280, 0, 0], + pid: 42, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }, + ]; + + worker.wakeWaitingParent(7); + + expect(worker.waitingForChild).toEqual([]); + expect(worker.completeWaitpid.mock.calls.map((call: unknown[]) => call.slice(2))) + .toEqual([[42, 0], [-1, 10]]); + expect(new DataView(processMemory.buffer).getInt32(1024, true)).toBe(3 << 8); + }); + + it("completes every matching WNOWAIT waiter while leaving a running waiter blocked", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const first = createChannel(7, processMemory, 0); + const second = createChannel(7, processMemory, 256); + const running = createChannel(7, processMemory, 512); + const waitChildPoll = vi.fn(( + _parentPid: number, + targetPid: number, + _eventMask: number, + _flags: number, + resultPtr: number, + ) => { + if (targetPid === 43) return 0; + writeKernelWaitResult(kernelMemory, resultPtr, { + waitStatus: 0, + siCode: WAIT_CLD_EXITED, + siStatus: 0, + childUid: 99, + rusage: new Uint8Array(STRUCT_SIZE_WASM_RUSAGE_WIRE), + }); + return 42; + }); + const worker = createWorkerHarness({ kernel_wait_child_poll: waitChildPoll }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[7, { + channels: [first, second, running], + memory: processMemory, + }]]); + worker.completeWaitid = vi.fn(); + const options = WAIT_WEXITED | WAIT_WNOWAIT; + const makeWaiter = (channel: any, pid: number, siginfoPtr: number) => ({ + parentPid: 7, + channel, + origArgs: [1, pid, siginfoPtr, options, 0], + pid, + options, + syscallNr: ABI_SYSCALLS.Waitid, + }); + const runningWaiter = makeWaiter(running, 43, 1536); + worker.waitingForChild = [ + makeWaiter(first, 42, 1024), + runningWaiter, + makeWaiter(second, 42, 1280), + ]; + + worker.wakeWaitingParent(7); + + expect(worker.waitingForChild).toEqual([runningWaiter]); + expect(worker.completeWaitid).toHaveBeenCalledTimes(2); + expect(waitChildPoll.mock.calls.filter((call: unknown[]) => call[1] === 42)) + .toEqual([ + [7, 42, WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128], + [7, 42, WAIT_EVENT_EXITED, WAIT_WNOWAIT, 128], + ]); + expect(new DataView(processMemory.buffer).getInt32(1024 + 12, true)).toBe(42); + expect(new DataView(processMemory.buffer).getInt32(1280 + 12, true)).toBe(42); + }); + + it("interrupts the exact host-deferred wait thread with its caught signal", () => { + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const processMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const channel = createChannel(7, processMemory); + const dequeue = vi.fn((_pid: number, outPtr: number) => { + const view = new DataView(kernelMemory.buffer); + view.setUint32(outPtr, SIGUSR1, true); + view.setUint32(outPtr + 8, SA_RESTART, true); + return SIGUSR1; + }); + const worker = createWorkerHarness({ + kernel_pick_signal_target_tid: vi.fn(() => 7), + kernel_dequeue_signal: dequeue, + }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + worker.wakeWaitingParent = vi.fn(); + worker.finishSignalTermination = vi.fn(() => false); + worker.completeChannel = vi.fn(); + + expect(worker.interruptWaitingChildForSignal(7, SIGUSR1)).toBe(true); + + expect(worker.waitingForChild).toEqual([]); + expect(worker.completeChannel).toHaveBeenCalledWith( + channel, + ABI_SYSCALLS.Wait4, + [-1, 0, 0, 0], + undefined, + -1, + 4, + ); + const signalView = new DataView(processMemory.buffer); + expect(signalView.getUint32(CH_SIG_SIGNUM, true)).toBe(SIGUSR1); + expect(signalView.getUint32(CH_SIG_FLAGS, true)).toBe(SA_RESTART); + }); + + it("removes and wakes an exact wait cancellation point", () => { + const memory = createSharedMemory(); + const caller = createChannel(7, memory, 0); + const target = createChannel(7, memory, 256); + const worker = createWorkerHarness({}); + worker.processes = new Map([[7, { + channels: [caller, target], + memory, + }]]); + worker.channelTids = new Map([["7:256", 99]]); + worker.pendingCancels = new Set(); + worker.pendingFutexWaits = new Map(); + worker.pendingPollRetries = new Map(); + worker.pendingSelectRetries = new Map(); + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + worker.waitingForChild = [{ + parentPid: 7, + channel: target, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + worker.completeChannelRaw = vi.fn(); + worker.relistenChannel = vi.fn(); + + worker.handleThreadCancel(caller, [99]); + + expect(worker.waitingForChild).toEqual([]); + expect(worker.pendingCancels.has(target)).toBe(true); + expect(worker.completeChannelRaw).toHaveBeenNthCalledWith(1, caller, 0, 0); + expect(worker.completeChannelRaw).toHaveBeenNthCalledWith( + 2, + target, + -4, + 4, + ); + expect(worker.relistenChannel).toHaveBeenCalledWith(target); + }); + + it("retires an interrupted engine futex waiter before a later wake quota", async () => { + const memory = createSharedMemory(); + const first = createChannel(7, memory, 0); + const second = createChannel(7, memory, 256); + const waker = createChannel(7, memory, 512); + const futexPtr = 4096; + new Int32Array(memory.buffer)[futexPtr >>> 2] = 0; + const worker = createWorkerHarness({}); + worker.processes = new Map([[7, { + channels: [first, second, waker], + memory, + }]]); + worker.pendingFutexWaits = new Map(); + worker.completeChannelRaw = vi.fn(); + worker.relistenChannel = vi.fn(); + + worker.handleFutex(first, [futexPtr, 0, 0, 0, 0, 0]); + worker.handleFutex(second, [futexPtr, 0, 0, 0, 0, 0]); + expect(worker.pendingFutexWaits.size).toBe(2); + + worker.pendingFutexWaits.get(first).interrupt(-4, 4); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(worker.pendingFutexWaits.size).toBe(0); + expect(worker.completeChannelRaw).toHaveBeenCalledWith(first, -4, 4); + expect(worker.completeChannelRaw).toHaveBeenCalledWith(second, 0, 0); + + worker.completeChannelRaw.mockClear(); + worker.handleFutex(second, [futexPtr, 0, 0, 0, 0, 0]); + expect(worker.pendingFutexWaits.size).toBe(1); + worker.handleFutex(waker, [futexPtr, 1, 1, 0, 0, 0]); + await new Promise((resolve) => setTimeout(resolve, 10)); + + expect(worker.completeChannelRaw).toHaveBeenCalledWith(waker, 1, 0); + expect(worker.completeChannelRaw).toHaveBeenCalledWith(second, 0, 0); + expect(worker.pendingFutexWaits.size).toBe(0); + }); + + it("retires futex and cancel state with an exact thread channel", () => { + const memory = createSharedMemory(); + const channel = createChannel(7, memory, 256); + const retire = vi.fn(); + const worker = createWorkerHarness({}); + worker.processes = new Map([[7, { channels: [channel], memory }]]); + worker.activeChannels = [channel]; + worker.stoppedPids = new Set(); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.resumePreparedSignals = new WeakSet(); + worker.pendingCancels = new Set([channel]); + worker.waitingForChild = []; + worker.pendingSleeps = new Map(); + worker.pendingFutexWaits = new Map([[channel, { + futexIndex: 1024, + retire, + }]]); + worker.pendingPollRetries = new Map(); + worker.pendingSelectRetries = new Map(); + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + worker.socketTimeoutTimers = new Map(); + worker.channelTids = new Map([["7:256", 99]]); + worker.threadForkContexts = new Map([["7:256", { fnPtr: 1, argPtr: 2 }]]); + + worker.removeChannel(7, 256); + + expect(retire).toHaveBeenCalledOnce(); + expect(worker.pendingFutexWaits.size).toBe(0); + expect(worker.pendingCancels.size).toBe(0); + expect(worker.processes.get(7).channels).toEqual([]); + expect(worker.activeChannels).toEqual([]); + expect(worker.channelTids.has("7:256")).toBe(false); + expect(worker.threadForkContexts.has("7:256")).toBe(false); + }); + + it("parks exact mailbox notifications while materializing completed output", () => { + const memory = createSharedMemory(); + const first = createChannel(42, memory, 0); + const second = createChannel(42, memory, 256); + markPending(first); + markPending(second); + const worker = createWorkerHarness({}); + worker.processes = new Map([[42, { channels: [first, second], memory }]]); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.synchronizeSharedMemoryForBoundary = vi.fn(); + worker.relistenChannel = vi.fn(); + + worker.publishOrParkChannelCompletion(first, { + kind: "marshalled", + outputWrites: [{ ptr: 2048, bytes: Uint8Array.of(1, 2, 3) }], + retVal: 7, + errVal: 0, + relistenRequested: true, + }); + worker.publishOrParkChannelCompletion(second, { + kind: "raw", + outputWrites: [], + retVal: 8, + errVal: 0, + relistenRequested: false, + }); + + expect(worker.parkedChannelCompletions.size).toBe(2); + expect(readStatus(first)).toBe(CHANNEL_STATUS_PENDING); + expect(readStatus(second)).toBe(CHANNEL_STATUS_PENDING); + // A peer mapping the same SharedArrayBuffer observes completed syscall + // output even though this stopped process remains parked at CH_PENDING. + expect(new Uint8Array(memory.buffer, 2048, 3)) + .toEqual(Uint8Array.of(1, 2, 3)); + expect(worker.synchronizeSharedMemoryForBoundary).toHaveBeenCalledTimes(2); + + worker.resumeStoppedProcess(42); + + expect(worker.parkedChannelCompletions.size).toBe(0); + expect(readStatus(first)).toBe(CHANNEL_STATUS_COMPLETE); + expect(readStatus(second)).toBe(CHANNEL_STATUS_COMPLETE); + expect(new DataView(memory.buffer, first.channelOffset).getBigInt64(CH_RETURN, true)).toBe(7n); + expect(new DataView(memory.buffer, second.channelOffset).getBigInt64(CH_RETURN, true)).toBe(8n); + expect(new Uint8Array(memory.buffer, 2048, 3)).toEqual(Uint8Array.of(1, 2, 3)); + expect(worker.relistenChannel).toHaveBeenCalledOnce(); + expect(worker.relistenChannel).toHaveBeenCalledWith(first); + }); + + it("delivers a caught SIGCONT before publishing the parked stop boundary", () => { + const kernelMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const processMemory = new WebAssembly.Memory({ + initial: 2, + maximum: 2, + shared: true, + }); + const channel = createChannel(42, processMemory); + const dequeue = vi.fn((_pid: number, outPtr: number) => { + new DataView(kernelMemory.buffer).setUint32(outPtr, SIGCONT, true); + return SIGCONT; + }); + const worker = createWorkerHarness({ + kernel_dequeue_signal: dequeue, + kernel_get_process_exit_signal: vi.fn(() => -1), + }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[42, { + channels: [channel], + memory: processMemory, + }]]); + worker.channelTids = new Map(); + worker.hostReaped = new Set(); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map([[channel, { + prepared: { + kind: "raw", + outputWrites: [], + retVal: 0, + errVal: 0, + relistenRequested: false, + }, + relistenRequested: false, + }]]); + worker.deferredStoppedChannels = new Map(); + worker.deferredProcessWorkerStarts = new Map(); + worker.publishPreparedChannelCompletion = vi.fn(); + + worker.resumeStoppedProcess(42); + + expect(dequeue).toHaveBeenCalledOnce(); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGCONT); + expect(worker.publishPreparedChannelCompletion).toHaveBeenCalledOnce(); + }); + + it("preflights every pthread before starting or publishing after SIGCONT", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const first = createChannel(42, processMemory, 0); + const second = createChannel(42, processMemory, 256); + markPending(first); + markPending(second); + + let state = PROCESS_STATE_STOPPED; + let currentTid = 0; + let secondScans = 0; + const dequeue = vi.fn((_pid: number, outPtr: number) => { + if (currentTid === 101) { + new DataView(kernelMemory.buffer).setUint32(outPtr, SIGCONT, true); + return SIGCONT; + } + secondScans++; + if (secondScans === 1) state = PROCESS_STATE_STOPPED; + return 0; + }); + const worker = createWorkerHarness({ + kernel_get_process_state: vi.fn(() => state), + kernel_set_current_tid: vi.fn((tid: number) => { currentTid = tid; }), + kernel_dequeue_signal: dequeue, + kernel_get_process_exit_signal: vi.fn(() => -1), + }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[42, { + channels: [first, second], + memory: processMemory, + }]]); + worker.channelTids = new Map([ + ["42:0", 101], + ["42:256", 102], + ]); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map([ + [first, parkedRaw(1)], + [second, parkedRaw(2)], + ]); + worker.deferredStoppedChannels = new Map(); + worker.deferredProcessWorkerStarts = new Map(); + worker.pendingSleeps = new Map(); + worker.pendingFutexWaits = new Map(); + worker.pendingPollRetries = new Map(); + worker.pendingSelectRetries = new Map(); + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + const start = vi.fn(); + const cancel = vi.fn(); + const publish = vi.fn(); + worker.publishPreparedChannelCompletion = publish; + + expect(worker.startProcessWorkerWhenRunnable( + 42, + processMemory, + start, + cancel, + )).toBe("deferred"); + + state = PROCESS_STATE_RUNNING; + expect(worker.resumeStoppedProcess(42)).toBe(false); + expect(start).not.toHaveBeenCalled(); + expect(publish).not.toHaveBeenCalled(); + expect(worker.parkedChannelCompletions.size).toBe(2); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGCONT); + + state = PROCESS_STATE_RUNNING; + expect(worker.resumeStoppedProcess(42)).toBe(true); + expect(start).toHaveBeenCalledOnce(); + expect(cancel).not.toHaveBeenCalled(); + expect(publish).toHaveBeenCalledTimes(2); + expect(dequeue).toHaveBeenCalledTimes(3); + // The first channel's caught signal was not dequeued/cleared again on the + // second resume attempt. + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGCONT); + }); + + it("interrupts a stopped exact wait thread with its retained caught signal", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel = createChannel(7, processMemory); + markPending(channel); + let state = PROCESS_STATE_STOPPED; + const dequeue = vi.fn((_pid: number, outPtr: number) => { + new DataView(kernelMemory.buffer).setUint32(outPtr, SIGUSR1, true); + return SIGUSR1; + }); + const worker = createWorkerHarness({ + kernel_get_process_state: vi.fn(() => state), + kernel_dequeue_signal: dequeue, + kernel_get_process_exit_signal: vi.fn(() => -1), + }); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[7, { + channels: [channel], + memory: processMemory, + }]]); + worker.waitingForChild = [{ + parentPid: 7, + channel, + origArgs: [-1, 0, 0, 0], + pid: -1, + options: 0, + syscallNr: ABI_SYSCALLS.Wait4, + }]; + worker.stoppedPids = new Set([7]); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.deferredProcessWorkerStarts = new Map(); + worker.pendingSleeps = new Map(); + worker.pendingFutexWaits = new Map(); + worker.pendingPollRetries = new Map(); + worker.pendingSelectRetries = new Map(); + worker.pendingPipeReaders = new Map(); + worker.pendingPipeWriters = new Map(); + worker.socketTimeoutTimers = new Map(); + worker.drainAllPtyOutputs = vi.fn(); + worker.flushTcpSendPipes = vi.fn(); + worker.drainAndProcessWakeupEvents = vi.fn(); + worker.synchronizeSharedMemoryForBoundary = vi.fn(); + worker.relistenChannel = vi.fn(); + const sequence: string[] = []; + const start = vi.fn(() => sequence.push("start")); + const cancel = vi.fn(); + worker.publishPreparedChannelCompletion = vi.fn((_channel: unknown, prepared: { + retVal: number; + errVal: number; + }) => { + sequence.push("publish"); + expect(prepared.retVal).toBe(-1); + expect(prepared.errVal).toBe(4); + }); + + expect(worker.startProcessWorkerWhenRunnable( + 7, + processMemory, + start, + cancel, + )).toBe("deferred"); + state = PROCESS_STATE_RUNNING; + expect(worker.resumeStoppedProcess(7)).toBe(true); + + expect(worker.waitingForChild).toEqual([]); + expect(dequeue).toHaveBeenCalledOnce(); + expect(sequence).toEqual(["start", "publish"]); + expect(new DataView(processMemory.buffer).getUint32(CH_SIG_SIGNUM, true)) + .toBe(SIGUSR1); + }); + + it("materializes stopped descriptor output before wake scratch is reused", () => { + const kernelMemory = createSharedMemory(); + const processMemory = createSharedMemory(); + const channel = createChannel(42, processMemory); + const outputPtr = 2048; + markPending(channel); + new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).set([9, 8, 7, 6]); + const worker = createWorkerHarness({}); + worker.kernelMemory = kernelMemory; + worker.processes = new Map([[42, { channels: [channel], memory: processMemory }]]); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.clearSocketTimeout = vi.fn(); + worker.clearReadinessWait = vi.fn(); + worker.drainAllPtyOutputs = vi.fn(); + worker.flushTcpSendPipes = vi.fn(); + const sequence: string[] = []; + worker.synchronizeSharedMemoryForBoundary = vi.fn(() => { + sequence.push("sync"); + }); + worker.relistenChannel = vi.fn(); + worker.drainAndProcessWakeupEvents = vi.fn(() => { + sequence.push("drain"); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) + .toEqual(Uint8Array.of(9, 8, 7, 6)); + new Uint8Array(kernelMemory.buffer, 128 + CH_DATA, 4).fill(0xee); + }); + + worker.completeChannel( + channel, + ABI_SYSCALLS.Read, + [0, outputPtr, 4], + [{ + argIndex: 1, + direction: "out", + size: { type: "arg", argIndex: 2 }, + }], + 4, + 0, + ); + + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) + .toEqual(Uint8Array.of(9, 8, 7, 6)); + expect(worker.synchronizeSharedMemoryForBoundary).toHaveBeenCalledOnce(); + expect(sequence).toEqual(["sync", "drain"]); + worker.resumeStoppedProcess(42); + expect(new Uint8Array(processMemory.buffer, outputPtr, 4)) + .toEqual(Uint8Array.of(9, 8, 7, 6)); + }); + + it("synchronizes raw completion before lifecycle wake observers", () => { + const memory = createSharedMemory(); + const channel = createChannel(42, memory); + markPending(channel); + const worker = createWorkerHarness({}); + worker.processes = new Map([[42, { channels: [channel], memory }]]); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map(); + worker.deferredStoppedChannels = new Map(); + worker.pendingCancels = new Set(); + worker.clearSocketTimeout = vi.fn(); + worker.clearReadinessWait = vi.fn(); + const sequence: string[] = []; + worker.synchronizeSharedMemoryForBoundary = vi.fn(() => { + sequence.push("sync"); + }); + worker.drainAndProcessWakeupEvents = vi.fn(() => { + sequence.push("drain"); + }); + + worker.completeChannelRaw(channel, 0, 0); + + expect(sequence).toEqual(["sync", "drain"]); + expect(readStatus(channel)).toBe(CHANNEL_STATUS_PENDING); + expect(worker.parkedChannelCompletions.has(channel)).toBe(true); + }); + + it("defers an exact retry while stopped and re-arms it on continuation", () => { + const channel = createChannel(42, createSharedMemory()); + const worker = createWorkerHarness({}); + worker.processes = new Map([[42, { channels: [channel], memory: channel.memory }]]); + worker.stoppedPids = new Set([42]); + worker.deferredStoppedChannels = new Map(); + worker.parkedChannelCompletions = new Map(); + const retrySyscall = worker.retrySyscall.bind(worker); + worker.handleSyscall = vi.fn(); + worker.relistenChannel = vi.fn(); + + retrySyscall(channel); + + expect(worker.handleSyscall).not.toHaveBeenCalled(); + expect(worker.deferredStoppedChannels.has(channel)).toBe(true); + worker.resumeStoppedProcess(42); + expect(worker.relistenChannel).toHaveBeenCalledWith(channel); + }); + + it("discards every parked and deferred channel without publication on signal death", () => { + const memory = createSharedMemory(); + const first = createChannel(42, memory, 0); + const second = createChannel(42, memory, 256); + markPending(first); + markPending(second); + const onExit = vi.fn(); + const worker = createWorkerHarness({ + kernel_get_process_exit_signal: vi.fn(() => 9), + }); + worker.processes = new Map([[42, { channels: [first, second], memory }]]); + worker.stoppedPids = new Set([42]); + worker.parkedChannelCompletions = new Map([ + [first, { + prepared: { + kind: "raw", + outputWrites: [], + retVal: 1, + errVal: 0, + relistenRequested: false, + }, + relistenRequested: false, + }], + ]); + worker.deferredStoppedChannels = new Map([[second, true]]); + worker.hostReaped = new Set(); + worker.releaseAllSharedMemoryForProcess = vi.fn(); + worker.notifyParentOfExitedProcess = vi.fn(); + worker.callbacks = { onExit }; + + worker.handleProcessTerminated(first); + + expect(worker.stoppedPids.has(42)).toBe(false); + expect(worker.parkedChannelCompletions.size).toBe(0); + expect(worker.deferredStoppedChannels.size).toBe(0); + expect(readStatus(first)).toBe(CHANNEL_STATUS_PENDING); + expect(readStatus(second)).toBe(CHANNEL_STATUS_PENDING); + expect(onExit).toHaveBeenCalledWith(42, 137); + }); + it("host-observed crashes are marked in Rust before parent notification", () => { const calls: string[] = []; const markProcessSignaled = vi.fn(() => { @@ -110,17 +1271,73 @@ describe("Rust-owned process wait lifecycle", () => { worker.hostReaped = new Set(); worker.sharedMappings = new Map([[42, new Map()]]); worker.sendSignalToProcess = vi.fn(() => calls.push("signal")); - worker.wakeWaitingParent = vi.fn(() => calls.push("wake")); worker.notifyHostProcessCrashed(42, 11); expect(markProcessSignaled).toHaveBeenCalledWith(42, 11); expect(worker.sendSignalToProcess).toHaveBeenCalledWith(7, SIGCHLD); - expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); - expect(calls).toEqual(["mark", "signal", "wake"]); + expect(calls).toEqual(["mark", "signal"]); expect(worker.sharedMappings.has(42)).toBe(false); }); + it("marks a host crash reaped before shared-state teardown can re-enter", () => { + const worker = createWorkerHarness({ + kernel_mark_process_signaled: vi.fn(() => 0), + }); + worker.hostReaped = new Set(); + worker.releaseAllSharedMemoryForProcess = vi.fn(() => { + expect(worker.hostReaped.has(42)).toBe(true); + }); + worker.notifyParentOfExitedProcess = vi.fn(); + + worker.notifyHostProcessCrashed(42, 11); + + expect(worker.releaseAllSharedMemoryForProcess).toHaveBeenCalledWith(42); + expect(worker.notifyParentOfExitedProcess).toHaveBeenCalledOnce(); + }); + + it("does not overwrite signal death discovered during clean-exit writeback", () => { + let exitSignal = 0; + const kernelHandle = vi.fn(); + const worker = createWorkerHarness({ + kernel_get_process_exit_signal: vi.fn(() => exitSignal), + kernel_handle_channel: kernelHandle, + }); + const channel = createChannel(42, createSharedMemory()); + worker.processes = new Map([[42, { channels: [channel] }]]); + worker.hostReaped = new Set(); + worker.releaseAllSharedMemoryForProcess = vi.fn(() => { + exitSignal = SIGTERM; + }); + worker.handleProcessTerminated = vi.fn(); + + worker.handleExit(channel, ABI_SYSCALLS.ExitGroup, [0]); + + expect(worker.handleProcessTerminated).toHaveBeenCalledWith(channel); + expect(kernelHandle).not.toHaveBeenCalled(); + }); + + it("uses the explicit termination signal instead of classifying high exit codes", () => { + const exitSignals = new Map([[42, 0], [43, 15]]); + const worker = createWorkerHarness({ + kernel_get_process_exit_signal: vi.fn((pid: number) => exitSignals.get(pid) ?? -1), + }); + const normalChannel = createChannel(42, createSharedMemory()); + const signaledChannel = createChannel(43, createSharedMemory()); + worker.processes = new Map([ + [42, { channels: [normalChannel] }], + [43, { channels: [signaledChannel] }], + ]); + worker.pendingSleeps = new Map(); + worker.hostReaped = new Set(); + worker.handleProcessTerminated = vi.fn(); + + worker.reapKilledProcessesAfterSyscall(); + + expect(worker.handleProcessTerminated).toHaveBeenCalledOnce(); + expect(worker.handleProcessTerminated).toHaveBeenCalledWith(signaledChannel); + }); + it("SA_NOCLDWAIT auto-reaps through Rust without SIGCHLD", () => { const reapExitedChild = vi.fn(() => 0); const worker = createWorkerHarness({ @@ -138,7 +1355,7 @@ describe("Rust-owned process wait lifecycle", () => { expect(reapExitedChild).toHaveBeenCalledWith(7, 42); expect(worker.sendSignalToProcess).not.toHaveBeenCalled(); - expect(worker.wakeWaitingParent).not.toHaveBeenCalled(); + expect(worker.wakeWaitingParent).toHaveBeenCalledWith(7); }); }); @@ -150,26 +1367,97 @@ function createWorkerHarness(exports: Record, kernelPtrWidth: 4 return kernelPtrWidth === 8 ? BigInt(numberValue) : numberValue; }, }, - kernelInstance: { exports }, + kernelInstance: { + exports: { + kernel_get_process_exit_signal: vi.fn(() => -1), + kernel_get_process_state: vi.fn(() => PROCESS_STATE_RUNNING), + ...exports, + }, + }, kernelMemory: createSharedMemory(), scratchOffset: 128, + processes: new Map(), + channelTids: new Map(), + pendingCancels: new Set(), + deferredProcessWorkerStarts: new Map(), }); } function createSharedMemory(): WebAssembly.Memory { return new WebAssembly.Memory({ - initial: 1, - maximum: 1, + initial: 2, + maximum: 2, shared: true, }); } -function createChannel(pid: number, memory: WebAssembly.Memory): any { +function createChannel(pid: number, memory: WebAssembly.Memory, channelOffset = 0): any { return { pid, memory, - channelOffset: 0, - i32View: new Int32Array(memory.buffer, 0), + channelOffset, + i32View: new Int32Array(memory.buffer, channelOffset), consecutiveSyscalls: 0, }; } + +function writeKernelWaitResult( + memory: WebAssembly.Memory, + ptr: number, + result: { + waitStatus: number; + siCode: number; + siStatus: number; + childUid: number; + rusage: Uint8Array; + }, +): void { + const view = new DataView(memory.buffer); + view.setInt32(ptr + KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET, result.waitStatus, true); + view.setInt32(ptr + KERNEL_WAIT_RESULT_SI_CODE_OFFSET, result.siCode, true); + view.setInt32(ptr + KERNEL_WAIT_RESULT_SI_STATUS_OFFSET, result.siStatus, true); + view.setUint32(ptr + KERNEL_WAIT_RESULT_CHILD_UID_OFFSET, result.childUid, true); + new Uint8Array(memory.buffer, ptr + KERNEL_WAIT_RESULT_RUSAGE_OFFSET, result.rusage.length) + .set(result.rusage); +} + +function writeWakeEvent( + memory: WebAssembly.Memory, + ptr: number, + index: number, + wakeIdx: number, + wakeType: number, +): void { + const offset = ptr + index * 5; + const view = new DataView(memory.buffer); + view.setUint32(offset, wakeIdx, true); + view.setUint8(offset + 4, wakeType); +} + +function markPending(channel: any): void { + Atomics.store( + new Int32Array(channel.memory.buffer, channel.channelOffset), + CH_STATUS / 4, + CHANNEL_STATUS_PENDING, + ); +} + +function parkedRaw(retVal: number): any { + return { + prepared: { + kind: "raw", + outputWrites: [], + retVal, + errVal: 0, + relistenRequested: false, + }, + relistenRequested: false, + }; +} + +function readStatus(channel: any): number { + return Atomics.load( + new Int32Array(channel.memory.buffer, channel.channelOffset), + CH_STATUS / 4, + ); +} diff --git a/host/test/pthread-channel-reuse.test.ts b/host/test/pthread-channel-reuse.test.ts new file mode 100644 index 0000000000..39a6c73c03 --- /dev/null +++ b/host/test/pthread-channel-reuse.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("pthread channel reuse", () => { + it("runs a second pthread after fork reuses the first thread slot", async () => { + const result = await runCentralizedProgram({ + programPath: join( + __dirname, + "../../examples/pthread_channel_reuse_test.wasm", + ), + argv: ["pthread-channel-reuse-test"], + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("PTHREAD_CHANNEL_REUSE_PASS"); + expect(result.stderr).toBe(""); + }); +}); diff --git a/host/test/pthread-trap-semantics.test.ts b/host/test/pthread-trap-semantics.test.ts index 7cd6b76d31..6105928e12 100644 --- a/host/test/pthread-trap-semantics.test.ts +++ b/host/test/pthread-trap-semantics.test.ts @@ -30,7 +30,7 @@ describe.skipIf(!hasPthreadTrapBinaries)("pthread trap POSIX semantics", () => { }, 15_000); it("terminates the process when a pthread worker hits an uncaught guest trap", async () => { - const { exitCode, stderr } = await runCentralizedProgram({ + const { exitCode, stderr, hostDiagnostics } = await runCentralizedProgram({ programPath: trapChildBinary, argv: ["pthread-trap-child"], timeout: 10_000, @@ -38,7 +38,9 @@ describe.skipIf(!hasPthreadTrapBinaries)("pthread trap POSIX semantics", () => { }); expect(stderr).toContain("pthread-trap-child: before trap"); - expect(stderr).toContain("Thread worker failed"); + expect(stderr).not.toContain("Thread worker failed"); + expect(hostDiagnostics.map((entry) => entry.message).join("\n")) + .toContain("Thread worker failed"); expect(stderr).not.toContain("FAIL pthread_join returned"); expect(exitCode).toBe(signalExitStatus(SIGILL)); }, 15_000); diff --git a/host/test/readiness-deadline.test.ts b/host/test/readiness-deadline.test.ts new file mode 100644 index 0000000000..95a174a2e4 --- /dev/null +++ b/host/test/readiness-deadline.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ABI_SYSCALLS, CH_SIG_BASE } from "../src/generated/abi"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +function createSharedMemory(pages = 1): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: pages, maximum: pages, shared: true }); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("finite readiness deadlines", () => { + it("keeps one poll deadline and performs a final readiness retry", () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + + const channel: any = { + pid: 42, + channelOffset: 0, + memory: createSharedMemory(), + }; + const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map([[channel.pid, { channels: [channel] }]]), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map(), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + }); + worker.resolvePollReadinessIndices = () => ({ pipeIndices: [], acceptIndices: [] }); + worker.completeChannel = vi.fn(); + + const args = [0, 1, 120, 0, 0, 0]; + const observedDeadlines: number[] = []; + let finalChecks = 0; + worker.retrySyscall = vi.fn(() => { + observedDeadlines.push(channel.readinessDeadline); + if (channel.readinessFinalCheck) { + // Model the zero-time kernel dispatch returning 0 after its final + // readiness check (and, for ppoll, restoring the temporary mask). + finalChecks++; + channel.readinessFinalCheck = false; + worker.completeChannel(channel, ABI_SYSCALLS.Poll, args, undefined, 0, 0); + return; + } + // Model the kernel's next nonblocking check returning EAGAIN again. + worker.handleBlockingRetry(channel, ABI_SYSCALLS.Poll, args); + }); + + worker.handleBlockingRetry(channel, ABI_SYSCALLS.Poll, args); + expect(channel.readinessDeadline).toBe(1_120); + + vi.advanceTimersByTime(119); + expect(worker.completeChannel).not.toHaveBeenCalled(); + expect(observedDeadlines).toEqual([1_120, 1_120]); + + vi.advanceTimersByTime(1); + expect(observedDeadlines).toEqual([1_120, 1_120, 1_120, 1_120]); + expect(finalChecks).toBe(1); + expect(worker.completeChannel).toHaveBeenCalledOnce(); + expect(worker.completeChannel.mock.calls[0].slice(-2)).toEqual([0, 0]); + }); + + it("postpones a signal-safe pselect fallback until the deferred wake", () => { + vi.useFakeTimers(); + vi.setSystemTime(2_000); + + const channel: any = { + pid: 42, + channelOffset: 64, + memory: createSharedMemory(), + }; + const earlyFallback = vi.fn(); + const entry: any = { + timer: setTimeout(earlyFallback, 1), + channel, + origArgs: [1, 0, 0, 0, 0, 0], + deadline: 2_100, + needsSignalSafeWake: true, + syscallNr: ABI_SYSCALLS.Pselect6, + }; + const worker: any = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map([[channel.pid, { channels: [channel] }]]), + pendingPollRetries: new Map(), + pendingSelectRetries: new Map([[channel, entry]]), + pendingPipeReaders: new Map(), + pendingPipeWriters: new Map(), + wakeScheduled: false, + }); + worker.handlePselect6 = vi.fn(); + worker.wakeAllBlockedRetries = vi.fn(); + + worker.scheduleWakeBlockedRetriesDeferred(); + + expect(entry.deadline).toBe(2_100); + vi.advanceTimersByTime(49); + expect(earlyFallback).not.toHaveBeenCalled(); + expect(worker.handlePselect6).not.toHaveBeenCalled(); + + vi.advanceTimersByTime(1); + expect(earlyFallback).not.toHaveBeenCalled(); + expect(worker.handlePselect6).toHaveBeenCalledOnce(); + expect(worker.handlePselect6).toHaveBeenCalledWith(channel, entry.origArgs); + expect(worker.pendingSelectRetries.has(channel)).toBe(false); + expect(worker.wakeAllBlockedRetries).toHaveBeenCalledOnce(); + }); +}); + +describe("host-emulated epoll signal delivery", () => { + it("interrupts epoll with EINTR after copying a caught handler signal", () => { + const harness = createEpollSignalHarness(15, 0); + + harness.worker.handleEpollPwait( + harness.channel, + ABI_SYSCALLS.EpollPwait, + [7, 4096, 1, 1000, 0, 8], + ); + + expect(harness.dequeueSignal).toHaveBeenCalledWith(harness.channel.pid, CH_SIG_BASE); + expect( + new DataView(harness.processMemory.buffer).getUint32(CH_SIG_BASE, true), + ).toBe(15); + expect(harness.completeChannelRaw).toHaveBeenCalledWith(harness.channel, -4, 4); + expect(harness.relistenChannel).toHaveBeenCalledWith(harness.channel); + expect(harness.handleProcessTerminated).not.toHaveBeenCalled(); + }); + + it("reaps a default signal death without waking guest epoll code", () => { + const harness = createEpollSignalHarness(0, 11, false); + + harness.worker.handleEpollPwait( + harness.channel, + ABI_SYSCALLS.EpollPwait, + [7, 4096, 1, 1000, 0, 8], + ); + + expect(harness.handleProcessTerminated).toHaveBeenCalledWith(harness.channel); + expect(harness.completeChannelRaw).not.toHaveBeenCalled(); + expect(harness.relistenChannel).not.toHaveBeenCalled(); + expect(harness.worker.pendingPollRetries.size).toBe(0); + expect(harness.handleChannel).not.toHaveBeenCalled(); + }); +}); + +function createEpollSignalHarness( + handlerSignal: number, + exitSignal: number, + hasInterest = true, +) { + const kernelMemory = createSharedMemory(2); + const processMemory = createSharedMemory(2); + const channel: any = { + pid: 42, + channelOffset: 0, + memory: processMemory, + }; + const dequeueSignal = vi.fn((_pid: number, outPtr: number) => { + if (handlerSignal > 0) { + new DataView(kernelMemory.buffer).setUint32(outPtr, handlerSignal, true); + } + return handlerSignal; + }); + const completeChannelRaw = vi.fn(); + const relistenChannel = vi.fn(); + const handleProcessTerminated = vi.fn(); + const handleChannel = vi.fn(() => 0); + 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), + }, + }, + kernelMemory, + scratchOffset: 0, + currentHandlePid: 0, + epollInterests: new Map([ + ["42:7", hasInterest ? [{ fd: 3, events: 0x001, data: 99n }] : []], + ]), + pendingPollRetries: new Map(), + pendingSleeps: new Map(), + bindKernelTidForChannel: vi.fn(), + completeChannelRaw, + relistenChannel, + handleProcessTerminated, + }); + return { + channel, + completeChannelRaw, + dequeueSignal, + handleChannel, + handleProcessTerminated, + processMemory, + relistenChannel, + worker, + }; +} diff --git a/host/test/rlimit-fsize.test.ts b/host/test/rlimit-fsize.test.ts new file mode 100644 index 0000000000..c590f1853a --- /dev/null +++ b/host/test/rlimit-fsize.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const programs = [ + ["wasm32", join(__dirname, "../../examples/rlimit_fsize_test.wasm")], + ["wasm64", join(__dirname, "../../examples/rlimit_fsize_test.wasm64.wasm")], +] as const; + +describe("RLIMIT_FSIZE operation boundaries", () => { + it.each(programs)( + "preserves partial progress for a %s guest", + async (_arch, program) => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["rlimit-fsize-test"], + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("RLIMIT_FSIZE_PASS"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/host/test/run-example-resolver.test.ts b/host/test/run-example-resolver.test.ts index 791020a352..0e38a66188 100644 --- a/host/test/run-example-resolver.test.ts +++ b/host/test/run-example-resolver.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "vitest"; -import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { spawnSync } from "node:child_process"; +import { isWithinRealDirectory } from "../../examples/run-example-paths"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "..", ".."); @@ -11,6 +12,29 @@ const runExample = join(repoRoot, "examples", "run-example.ts"); const spawnSmokeWasm = join(repoRoot, "examples", "spawn-smoke.wasm"); describe("run-example exec resolver", () => { + it("compares canonical workdir paths without allowing symlink escapes", () => { + const tempDir = mkdtempSync(join(tmpdir(), "kandelo-workdir-boundary-")); + const realWorkdir = join(tempDir, "real-workdir"); + const workdirAlias = join(tempDir, "workdir-alias"); + const outsideDir = join(tempDir, "outside"); + const guestProgram = join(realWorkdir, "guest-program"); + const outsideProgram = join(outsideDir, "host-program"); + const escapedProgram = join(realWorkdir, "escaped-program"); + try { + mkdirSync(realWorkdir); + mkdirSync(outsideDir); + writeFileSync(guestProgram, "guest"); + writeFileSync(outsideProgram, "host"); + symlinkSync(realWorkdir, workdirAlias, "dir"); + symlinkSync(outsideProgram, escapedProgram, "file"); + + expect(isWithinRealDirectory(workdirAlias, guestProgram)).toBe(true); + expect(isWithinRealDirectory(workdirAlias, escapedProgram)).toBe(false); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); + it("does not resolve native host executables outside KERNEL_CWD as guest programs", () => { const tempDir = mkdtempSync(join(tmpdir(), "kandelo-host-native-")); const nativeLikeBinary = join(tempDir, "host-tool"); diff --git a/host/test/sched-getaffinity.test.ts b/host/test/sched-getaffinity.test.ts new file mode 100644 index 0000000000..989c0900bd --- /dev/null +++ b/host/test/sched-getaffinity.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { resolveBinary } from "../src/binary-resolver"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +describe("sched_getaffinity", () => { + it.each([ + ["wasm32", "programs/sched-getaffinity.wasm"], + ["wasm64", "programs/wasm64/sched-getaffinity.wasm"], + ])( + "preserves Linux raw and libc semantics for %s", + async (_arch, path) => { + const result = await runCentralizedProgram({ + programPath: resolveBinary(path), + timeout: 10_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toBe("sched-getaffinity-ok raw=4 cpus=1\n"); + expect(result.stderr).toBe(""); + }, + 30_000, + ); +}); 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/shared-memory-coherence.test.ts b/host/test/shared-memory-coherence.test.ts new file mode 100644 index 0000000000..a65eb22ca7 --- /dev/null +++ b/host/test/shared-memory-coherence.test.ts @@ -0,0 +1,377 @@ +import { describe, expect, it, vi } from "vitest"; +import { CentralizedKernelWorker } from "../src/kernel-worker"; + +function sharedMemory(): WebAssembly.Memory { + return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); +} + +function anonymousHarness() { + const parentPid = 41; + const peerPid = 42; + const childPid = 43; + const mapAddr = 0x2000; + const len = 256; + const key = "anon:test"; + const parentMemory = sharedMemory(); + const peerMemory = sharedMemory(); + const childMemory = sharedMemory(); + const backing = { + key, + bytes: new Uint8Array(len), + refCount: 2, + version: 0, + }; + const mapping = () => ({ + fd: -1, + fileOffset: 0, + len, + writable: true, + backingKey: key, + snapshot: new Uint8Array(len), + seenVersion: 0, + }); + const channel = (pid: number, memory: WebAssembly.Memory) => ({ + pid, + memory, + channelOffset: 0, + i32View: new Int32Array(memory.buffer, 0, 1), + consecutiveSyscalls: 0, + }); + const parentChannel = channel(parentPid, parentMemory); + const peerChannel = channel(peerPid, peerMemory); + const childChannel = channel(childPid, childMemory); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + anonymousSharedBackings: new Map([[key, backing]]), + sharedMappings: new Map([ + [parentPid, new Map([[mapAddr, mapping()]])], + [peerPid, new Map([[mapAddr, mapping()]])], + ]), + shmMappings: new Map(), + processes: new Map([ + [parentPid, { pid: parentPid, memory: parentMemory, channels: [parentChannel] }], + [peerPid, { pid: peerPid, memory: peerMemory, channels: [peerChannel] }], + [childPid, { pid: childPid, memory: childMemory, channels: [childChannel] }], + ]), + }) as CentralizedKernelWorker; + return { + backing, + childMemory, + childPid, + key, + kw, + len, + mapAddr, + parentMemory, + parentPid, + peerMemory, + peerPid, + }; +} + +describe("anonymous MAP_SHARED coherence", () => { + it("merges stale same-page publishers without losing disjoint peer writes", () => { + const h = anonymousHarness(); + const parent = new Uint8Array(h.parentMemory.buffer); + const peer = new Uint8Array(h.peerMemory.buffer); + + parent[h.mapAddr + 11] = 0xa1; + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + ); + + peer[h.mapAddr + 29] = 0xb2; + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.peerPid), + ); + + expect(h.backing.bytes[11]).toBe(0xa1); + expect(h.backing.bytes[29]).toBe(0xb2); + expect(peer[h.mapAddr + 11]).toBe(0xa1); + expect(peer[h.mapAddr + 29]).toBe(0xb2); + + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + ); + expect(parent[h.mapAddr + 29]).toBe(0xb2); + }); + + it("force-publishes a sole observer before fork and inherits one backing", () => { + const h = anonymousHarness(); + (h.kw as any).sharedMappings.delete(h.peerPid); + h.backing.refCount = 1; + new Uint8Array(h.parentMemory.buffer)[h.mapAddr + 7] = 0x7c; + + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + { force: true }, + ); + h.kw.inheritProcessSharedMappings(h.parentPid, h.childPid); + + expect(h.backing.bytes[7]).toBe(0x7c); + expect(new Uint8Array(h.childMemory.buffer)[h.mapAddr + 7]).toBe(0x7c); + expect(h.backing.refCount).toBe(2); + expect((h.kw as any).sharedMappings.get(h.childPid).size).toBe(1); + }); + + it("refreshes a sole parent after its child publishes and detaches", () => { + const h = anonymousHarness(); + (h.kw as any).sharedMappings.delete(h.peerPid); + h.backing.refCount = 1; + h.kw.inheritProcessSharedMappings(h.parentPid, h.childPid); + + const child = new Uint8Array(h.childMemory.buffer); + child[h.mapAddr + 17] = 0x6d; + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.childPid), + ); + expect(h.backing.bytes[17]).toBe(0x6d); + expect(h.backing.refCount).toBe(2); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.childPid); + expect(h.backing.refCount).toBe(1); + + const parent = new Uint8Array(h.parentMemory.buffer); + expect(parent[h.mapAddr + 17]).toBe(0); + (h.kw as any).syncAnonymousSharedMappingsFromProcess( + (h.kw as any).processes.get(h.parentPid), + ); + expect(parent[h.mapAddr + 17]).toBe(0x6d); + }); + + it("publishes and releases backing references exactly once at teardown", () => { + const h = anonymousHarness(); + new Uint8Array(h.parentMemory.buffer)[h.mapAddr + 3] = 0x55; + + (h.kw as any).releaseAllSharedMemoryForProcess(h.parentPid); + expect(h.backing.bytes[3]).toBe(0x55); + expect(h.backing.refCount).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.parentPid); + expect(h.backing.refCount).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.peerPid); + expect((h.kw as any).anonymousSharedBackings.has(h.key)).toBe(false); + }); + + it("rejects a stale pre-exec memory generation at a coherence boundary", () => { + const h = anonymousHarness(); + const replacement = sharedMemory(); + (h.kw as any).processes.set(h.parentPid, { + pid: h.parentPid, + memory: replacement, + channels: [], + }); + new Uint8Array(h.parentMemory.buffer)[h.mapAddr + 1] = 0xff; + + (h.kw as any).synchronizeSharedMemoryForBoundary({ + pid: h.parentPid, + memory: h.parentMemory, + }); + + expect(h.backing.bytes[1]).toBe(0); + }); + + it("skips coherence scans when no process has shared mappings", () => { + const pid = 51; + const process = { pid, memory: sharedMemory() }; + const processes = new Map([[pid, process]]); + const getProcess = vi.spyOn(processes, "get"); + const syncAnonymous = vi.fn(); + const syncFile = vi.fn(); + const syncSysv = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes, + sharedMappings: new Map(), + shmMappings: new Map(), + syncAnonymousSharedMappingsFromProcess: syncAnonymous, + syncFileSharedMappingsFromProcess: syncFile, + syncSysvShmMappingsFromProcess: syncSysv, + }) as CentralizedKernelWorker; + + (kw as any).synchronizeSharedMemoryForBoundary(process); + + expect(getProcess).toHaveBeenCalledWith(pid); + expect(syncAnonymous).not.toHaveBeenCalled(); + expect(syncFile).not.toHaveBeenCalled(); + expect(syncSysv).not.toHaveBeenCalled(); + }); + + it.each(["POSIX", "SysV"])( + "runs coherence scans while %s shared mappings exist", + (mappingKind) => { + const pid = 52; + const process = { pid, memory: sharedMemory() }; + const syncAnonymous = vi.fn(); + const syncFile = vi.fn(); + const syncSysv = vi.fn(); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + processes: new Map([[pid, process]]), + sharedMappings: mappingKind === "POSIX" + ? new Map([[pid, new Map([[0x1000, {}]])]]) + : new Map(), + shmMappings: mappingKind === "SysV" + ? new Map([[pid, new Map([[0x2000, {}]])]]) + : new Map(), + syncAnonymousSharedMappingsFromProcess: syncAnonymous, + syncFileSharedMappingsFromProcess: syncFile, + syncSysvShmMappingsFromProcess: syncSysv, + }) as CentralizedKernelWorker; + + (kw as any).synchronizeSharedMemoryForBoundary(process); + + expect(syncAnonymous).toHaveBeenCalledWith(process); + expect(syncFile).toHaveBeenCalledWith(process); + expect(syncSysv).toHaveBeenCalledWith(process); + }, + ); +}); + +function sysvHarness() { + const pids = [61, 62, 63]; + const mapAddr = 0x3000; + const size = 256; + const segId = 9; + const memories = new Map(pids.map((pid) => [pid, sharedMemory()])); + const kernelMemory = new WebAssembly.Memory({ initial: 2 }); + const segment = new Uint8Array(size); + const setCurrentPid = vi.fn(); + const shmat = vi.fn(() => size); + const shmdt = vi.fn(() => 0); + const readChunk = vi.fn((id: number, offset: number, outPtr: number, maxLen: number) => { + expect(id).toBe(segId); + const len = Math.min(maxLen, segment.length - offset); + new Uint8Array(kernelMemory.buffer).set(segment.subarray(offset, offset + len), outPtr); + return len; + }); + const writeChunk = vi.fn((id: number, offset: number, dataPtr: number, len: number) => { + expect(id).toBe(segId); + segment.set(new Uint8Array(kernelMemory.buffer, dataPtr, len), offset); + return len; + }); + const mapping = (readOnly = false) => ({ + segId, + size, + readOnly, + snapshot: new Uint8Array(size), + seenVersion: 0, + }); + const processes = new Map(pids.map((pid) => { + const memory = memories.get(pid)!; + return [pid, { pid, memory, channels: [{ pid, memory, channelOffset: 0 }] }]; + })); + const kw = Object.assign(Object.create(CentralizedKernelWorker.prototype), { + currentHandlePid: 0, + kernel: { toKernelPtr: (value: number | bigint) => Number(value) }, + kernelMemory, + kernelInstance: { + exports: { + kernel_set_current_pid: setCurrentPid, + kernel_ipc_shmat: shmat, + kernel_ipc_shmdt: shmdt, + kernel_ipc_shm_read_chunk: readChunk, + kernel_ipc_shm_write_chunk: writeChunk, + }, + }, + scratchOffset: 0, + processes, + sharedMappings: new Map(), + anonymousSharedBackings: new Map(), + shmMappings: new Map([ + [pids[0], new Map([[mapAddr, mapping()]])], + [pids[1], new Map([[mapAddr, mapping()]])], + ]), + shmSegmentVersions: new Map([[segId, 0]]), + }) as CentralizedKernelWorker; + return { kw, mapAddr, memories, pids, segment, segId, shmat, shmdt, size }; +} + +describe("SysV SHM coherence and lifecycle", () => { + it("merges stale same-page publishers and refreshes the later publisher", () => { + const h = sysvHarness(); + const first = new Uint8Array(h.memories.get(h.pids[0])!.buffer); + const second = new Uint8Array(h.memories.get(h.pids[1])!.buffer); + first[h.mapAddr + 5] = 0x15; + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[0]), + ); + second[h.mapAddr + 19] = 0x29; + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[1]), + ); + + expect(h.segment[5]).toBe(0x15); + expect(h.segment[19]).toBe(0x29); + expect(second[h.mapAddr + 5]).toBe(0x15); + expect(second[h.mapAddr + 19]).toBe(0x29); + }); + + it("never publishes a SHM_RDONLY attachment but still refreshes it", () => { + const h = sysvHarness(); + const readonlyMap = (h.kw as any).shmMappings.get(h.pids[1]).get(h.mapAddr); + readonlyMap.readOnly = true; + const first = new Uint8Array(h.memories.get(h.pids[0])!.buffer); + const second = new Uint8Array(h.memories.get(h.pids[1])!.buffer); + second[h.mapAddr + 8] = 0xee; + first[h.mapAddr + 14] = 0x44; + + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[0]), + ); + (h.kw as any).syncSysvShmMappingsFromProcess( + (h.kw as any).processes.get(h.pids[1]), + ); + + expect(h.segment[8]).toBe(0); + expect(h.segment[14]).toBe(0x44); + expect(second[h.mapAddr + 8]).toBe(0); + expect(second[h.mapAddr + 14]).toBe(0x44); + }); + + it("increments inherited nattch and detaches the child exactly once", () => { + const h = sysvHarness(); + h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2]); + expect(h.shmat).toHaveBeenCalledWith(h.segId, h.mapAddr, 0); + expect((h.kw as any).shmMappings.get(h.pids[2]).size).toBe(1); + + (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); + expect(h.shmdt).toHaveBeenCalledTimes(1); + (h.kw as any).releaseAllSharedMemoryForProcess(h.pids[2]); + expect(h.shmdt).toHaveBeenCalledTimes(1); + }); + + it("rolls back attachments when inherited SysV setup fails", () => { + const h = sysvHarness(); + const secondAddr = h.mapAddr + 0x1000; + (h.kw as any).shmMappings.get(h.pids[0]).set(secondAddr, { + segId: h.segId, + size: h.size, + readOnly: false, + snapshot: new Uint8Array(h.size), + seenVersion: 0, + }); + h.shmat.mockImplementationOnce(() => h.size).mockImplementationOnce(() => -12); + + expect(() => h.kw.inheritProcessSharedMappings(h.pids[0], h.pids[2])).toThrow(); + expect(h.shmdt).toHaveBeenCalledTimes(1); + expect((h.kw as any).shmMappings.has(h.pids[2])).toBe(false); + }); + + it("rolls back kernel nattch when host mmap allocation fails", () => { + const h = sysvHarness(); + const complete = vi.fn(); + const relisten = vi.fn(); + Object.assign(h.kw as any, { + shmMappings: new Map(), + runSyntheticMemorySyscall: vi.fn(() => ({ retVal: -1, errVal: 12 })), + completeChannelRaw: complete, + relistenChannel: relisten, + }); + const memory = h.memories.get(h.pids[2])!; + const channel = { pid: h.pids[2], memory, channelOffset: 0 }; + + (h.kw as any).handleIpcShmat(channel, [h.segId, 0, 0]); + expect(h.shmdt).toHaveBeenCalledTimes(1); + expect(complete).toHaveBeenCalledWith(channel, -12, 12); + expect(relisten).toHaveBeenCalledWith(channel); + }); +}); diff --git a/host/test/sharedfs-safety.test.ts b/host/test/sharedfs-safety.test.ts new file mode 100644 index 0000000000..e1da659423 --- /dev/null +++ b/host/test/sharedfs-safety.test.ts @@ -0,0 +1,958 @@ +import { describe, expect, it, vi } from "vitest"; +import { Worker } from "node:worker_threads"; +import { MemoryFileSystem } from "../src/vfs/memory-fs"; + +const O_RDONLY = 0x0000; +const O_WRONLY = 0x0001; +const O_RDWR = 0x0002; +const O_CREAT = 0x0040; +const O_TRUNC = 0x0200; +const SEEK_SET = 0; + +function create(size = 4 * 1024 * 1024): MemoryFileSystem { + return MemoryFileSystem.create(new SharedArrayBuffer(size)); +} + +function listDir(fs: MemoryFileSystem, path: string): string[] { + const dd = fs.opendir(path); + const names: string[] = []; + try { + for (;;) { + const entry = fs.readdir(dd); + if (!entry) return names; + if (entry.name !== "." && entry.name !== "..") names.push(entry.name); + } + } finally { + fs.closedir(dd); + } +} + +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); + const sparseSize = 3_000_000_000; + + const started = performance.now(); + fs.ftruncate(fd, sparseSize); + expect(performance.now() - started).toBeLessThan(500); + expect(fs.fstat(fd).size).toBe(sparseSize); + + fs.seek(fd, sparseSize - 1, SEEK_SET); + expect(fs.write(fd, new Uint8Array([0x7a]), null, 1)).toBe(1); + const tail = new Uint8Array(4); + expect(fs.read(fd, tail, sparseSize - 3, tail.length)).toBe(3); + expect(Array.from(tail.subarray(0, 3))).toEqual([0, 0, 0x7a]); + fs.close(fd); + }); + + it("rejects invalid and unrepresentable truncate lengths", () => { + const fs = create(); + const fd = fs.open("/file", O_CREAT | O_RDWR, 0o644); + expect(() => fs.ftruncate(fd, -1)).toThrow(/Invalid argument/); + expect(() => fs.ftruncate(fd, Number.NaN)).toThrow(/Invalid argument/); + expect(() => fs.ftruncate(fd, Number.MAX_SAFE_INTEGER)).toThrow( + /File too large/, + ); + expect(fs.fstat(fd).size).toBe(0); + fs.close(fd); + }); + + it("commits size and data for a positive partial write at ENOSPC", () => { + const fs = create(128 * 1024); + const fd = fs.open("/partial", O_CREAT | O_RDWR | O_TRUNC, 0o644); + const input = new Uint8Array(1024 * 1024).fill(0xa5); + const written = fs.write(fd, input, null, input.length); + + expect(written).toBeGreaterThan(0); + expect(written).toBeLessThan(input.length); + expect(fs.fstat(fd).size).toBe(written); + const tail = new Uint8Array(1); + expect(fs.read(fd, tail, written - 1, 1)).toBe(1); + expect(tail[0]).toBe(0xa5); + fs.close(fd); + }); +}); + +describe("SharedFS namespace and image safety", () => { + it("hard-links a symlink inode without following its target", () => { + const fs = create(); + const target = fs.open("/target", O_CREAT | O_WRONLY, 0o644); + fs.close(target); + fs.symlink("/target", "/symbolic"); + + fs.link("/symbolic", "/alias"); + expect(fs.lstat("/alias").mode & 0xf000).toBe(0xa000); + expect(fs.lstat("/alias").nlink).toBe(2); + expect(fs.readlink("/alias")).toBe("/target"); + + fs.unlink("/symbolic"); + expect(fs.lstat("/alias").mode & 0xf000).toBe(0xa000); + expect(fs.lstat("/alias").nlink).toBe(1); + }); + + it("prevents path ABA across workers without deadlocking", async () => { + const fs = create(); + const initial = fs.open("/slot", O_CREAT | O_WRONLY | O_TRUNC, 0o644); + fs.write(initial, new Uint8Array([0x11]), null, 1); + fs.close(initial); + + const controlBuffer = new SharedArrayBuffer(4); + const control = new Int32Array(controlBuffer); + const workerUrl = new URL( + "./fixtures/sharedfs-namespace-worker.ts", + import.meta.url, + ); + const makeWorker = (role: "mutator" | "observer") => + new Worker(workerUrl, { + execArgv: ["--import", "tsx"], + workerData: { + fsBuffer: fs.sharedBuffer, + controlBuffer, + role, + iterations: 4_000, + }, + }); + const workers = [makeWorker("mutator"), makeWorker("observer")]; + const results = workers.map( + (worker) => + new Promise<{ ok: boolean; error?: string }>((resolve, reject) => { + worker.once("message", resolve); + worker.once("error", reject); + worker.once("exit", (code) => { + if (code !== 0) reject(new Error(`SharedFS worker exited ${code}`)); + }); + }), + ); + + Atomics.store(control, 0, 1); + Atomics.notify(control, 0, workers.length); + try { + const completed = Promise.all(results); + const timeout = new Promise((_, reject) => { + setTimeout( + () => reject(new Error("SharedFS worker watchdog expired")), + 8_000, + ); + }); + const messages = await Promise.race([completed, timeout]); + expect(messages).toEqual([{ ok: true }, { ok: true }]); + } finally { + await Promise.all(workers.map((worker) => worker.terminate())); + } + }, 10_000); + + it("polls safely when browser-main-style Atomics.wait is unavailable", async () => { + const fs = create(); + const worker = new Worker( + new URL("./fixtures/sharedfs-lock-release-worker.mjs", import.meta.url), + { workerData: fs.sharedBuffer }, + ); + await new Promise((resolve, reject) => { + worker.once("message", () => resolve()); + worker.once("error", reject); + }); + + const wait = vi.spyOn(Atomics, "wait").mockImplementation(() => { + throw new TypeError("Atomics.wait cannot be called on this thread"); + }); + try { + expect(fs.stat("/").mode & 0xf000).toBe(0x4000); + expect(wait).toHaveBeenCalled(); + } finally { + wait.mockRestore(); + await worker.terminate(); + } + }); + + it("leaves a full directory intact after repeated create and rename failures", () => { + const fs = create(128 * 1024); + const filler = fs.open("/filler", O_CREAT | O_WRONLY | O_TRUNC, 0o644); + fs.write(filler, new Uint8Array(1024 * 1024), null, 1024 * 1024); + fs.close(filler); + + const created: string[] = []; + let failedName = ""; + for (let i = 0; i < 64; i++) { + const name = `/${String(i).padStart(2, "0")}-${"x".repeat(180)}`; + try { + const fd = fs.open(name, O_CREAT | O_WRONLY, 0o644); + fs.close(fd); + created.push(name); + } catch (error) { + expect(String(error)).toMatch(/No space left/); + failedName = name; + break; + } + } + + expect(failedName).not.toBe(""); + const before = listDir(fs, "/").sort(); + for (let attempt = 0; attempt < 3; attempt++) { + expect(() => fs.open(failedName, O_CREAT | O_WRONLY, 0o644)).toThrow( + /No space left/, + ); + expect(listDir(fs, "/").sort()).toEqual(before); + } + + const source = created[0]; + const destination = `/${"r".repeat(220)}`; + expect(() => fs.rename(source, destination)).toThrow(/No space left/); + expect(fs.stat(source).mode & 0xf000).toBe(0x8000); + expect(() => fs.stat(destination)).toThrow(/No such file/); + expect(listDir(fs, "/").sort()).toEqual(before); + }); + + it("requires quiescent snapshots and clears legacy runtime state on restore", async () => { + const fs = create(); + const fd = fs.open("/saved", O_CREAT | O_RDWR, 0o644); + await expect(fs.saveImage()).rejects.toThrow(/open descriptors/); + fs.close(fd); + const orphanFd = fs.open("/orphan", O_CREAT | O_RDWR, 0o644); + const orphanIno = fs.fstat(orphanFd).ino; + fs.close(orphanFd); + + const image = await fs.saveImage(); + const imageView = new DataView( + image.buffer, + image.byteOffset, + image.byteLength, + ); + const sabOffset = 16; + imageView.setUint32(sabOffset + 60, 1, true); // stale grow lock + imageView.setUint32(sabOffset + 64, 1, true); // stale namespace lock + imageView.setUint32(sabOffset + 256, 1, true); // stale fd 0 + + const inodeTableBlock = imageView.getUint32(sabOffset + 36, true); + const ino = fs.stat("/saved").ino; + const inodeOffset = sabOffset + inodeTableBlock * 4096 + (ino % 32) * 128; + imageView.setUint32(inodeOffset, 0x80000000, true); // stale inode lock + imageView.setUint32(inodeOffset + 112, 1, true); // stale open ref + + const orphanOffset = + sabOffset + inodeTableBlock * 4096 + (orphanIno % 32) * 128; + imageView.setUint32(orphanOffset + 12, 0, true); // unlinked + imageView.setUint32(orphanOffset + 112, 1, true); // but held open + imageView.setUint32(sabOffset + 256 + 4, orphanIno, true); + + const rootOffset = sabOffset + inodeTableBlock * 4096 + 128; + const rootBlock = imageView.getUint32(rootOffset + 48, true); + const rootSize = Number(imageView.getBigUint64(rootOffset + 16, true)); + const decoder = new TextDecoder(); + for (let pos = 0; pos < rootSize;) { + const entryOffset = sabOffset + rootBlock * 4096 + pos; + const recLen = imageView.getUint16(entryOffset + 4, true); + const nameLen = imageView.getUint16(entryOffset + 6, true); + const name = decoder.decode( + image.subarray(entryOffset + 8, entryOffset + 8 + nameLen), + ); + if (name === "orphan") imageView.setUint32(entryOffset, 0, true); + pos += recLen; + } + + const restored = MemoryFileSystem.fromImage(image); + expect(restored.stat("/saved").ino).toBe(ino); + expect(() => restored.stat("/orphan")).toThrow(/No such file/); + const replacement = restored.open( + "/replacement", + O_CREAT | O_WRONLY, + 0o644, + ); + expect(restored.fstat(replacement).ino).toBe(orphanIno); + restored.close(replacement); + expect(() => restored.close(0)).toThrow(/Bad file descriptor/); + await expect(restored.saveImage()).resolves.toBeInstanceOf(Uint8Array); + }); + + it("fails closed instead of removing a corrupt directory", () => { + const fs = create(); + fs.mkdir("/empty", 0o755); + const ino = fs.stat("/empty").ino; + const view = new DataView(fs.sharedBuffer); + const inodeTableBlock = view.getUint32(36, true); + const inodeOffset = inodeTableBlock * 4096 + (ino % 32) * 128; + const dataBlock = view.getUint32(inodeOffset + 48, true); + const recLenOffset = dataBlock * 4096 + 4; + const originalRecLen = view.getUint16(recLenOffset, true); + + view.setUint16(recLenOffset, 0, true); + expect(() => fs.rmdir("/empty")).toThrow(/I\/O error/); + expect(fs.stat("/empty").ino).toBe(ino); + + view.setUint16(recLenOffset, originalRecLen, true); + fs.rmdir("/empty"); + expect(() => fs.stat("/empty")).toThrow(/No such file/); + }); + + it("rejects a directory entry that names a free inode slot", () => { + const fs = create(); + const fd = fs.open("/victim", O_CREAT | O_WRONLY, 0o644); + fs.close(fd); + const view = new DataView(fs.sharedBuffer); + const inodeTableBlock = view.getUint32(36, true); + const rootOffset = inodeTableBlock * 4096 + 128; + const rootBlock = view.getUint32(rootOffset + 48, true); + const rootSize = Number(view.getBigUint64(rootOffset + 16, true)); + const decoder = new TextDecoder(); + let victimEntry = -1; + for (let pos = 0; pos < rootSize;) { + const abs = rootBlock * 4096 + pos; + const recLen = view.getUint16(abs + 4, true); + const nameLen = view.getUint16(abs + 6, true); + const name = decoder.decode( + new Uint8Array(fs.sharedBuffer, abs + 8, nameLen), + ); + if (name === "victim") victimEntry = abs; + pos += recLen; + } + expect(victimEntry).toBeGreaterThan(0); + + const freeInode = view.getUint32(16, true) - 1; + view.setUint32(victimEntry, freeInode, true); + const freeBefore = fs.statfs("/").ffree; + expect(() => fs.stat("/victim")).toThrow(/I\/O error/); + expect(() => fs.unlink("/victim")).toThrow(/I\/O error/); + expect(fs.statfs("/").ffree).toBe(freeBefore); + }); +}); + +describe("MemoryFileSystem lazy inode identity", () => { + it("atomically replaces an existing file with lazy backing", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + const existing = fs.open("/lazy", O_CREAT | O_WRONLY | O_TRUNC, 0o644); + fs.write(existing, new Uint8Array([9]), null, 1); + fs.close(existing); + + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + expect(fs.getLazyEntry("/lazy")).toMatchObject({ size: 1 }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + await expect(fs.ensureMaterialized("/lazy")).resolves.toBe(true); + const fd = fs.open("/lazy", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(7); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not overwrite a peer write after replacing an existing lazy path", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + const existing = fs.open("/lazy", O_CREAT | O_WRONLY | O_TRUNC, 0o644); + fs.write(existing, new Uint8Array([4]), null, 1); + fs.close(existing); + + const raw = ( + fs as unknown as { + fs: { + createLazyStub: ( + path: string, + mode: number, + ) => { ino: number; generation: number; dataSequence: number }; + }; + } + ).fs; + const createLazyStub = raw.createLazyStub.bind(raw); + const createSpy = vi + .spyOn(raw, "createLazyStub") + .mockImplementation((path, mode) => { + const identity = createLazyStub(path, mode); + const writer = peer.open(path, O_WRONLY, 0o644); + peer.write(writer, new Uint8Array([9]), null, 1); + peer.close(writer); + return identity; + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + expect(fs.getLazyEntry("/lazy")).toBeNull(); + await expect(fs.ensureMaterialized("/lazy")).resolves.toBe(false); + const fd = fs.open("/lazy", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(9); + } finally { + createSpy.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("binds registration to the atomically-created stub identity", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + type StubIdentity = ReturnType<(typeof fs)["registerLazyFile"]>; + const raw = ( + fs as unknown as { + fs: { + createLazyStub: ( + path: string, + mode: number, + ) => { + ino: StubIdentity; + generation: number; + dataSequence: number; + }; + }; + } + ).fs; + const createLazyStub = raw.createLazyStub.bind(raw); + const createSpy = vi + .spyOn(raw, "createLazyStub") + .mockImplementation((path, mode) => { + const identity = createLazyStub(path, mode); + peer.rename(path, "/moved"); + const replacement = peer.open(path, O_CREAT | O_WRONLY, 0o644); + peer.write(replacement, new Uint8Array([9]), null, 1); + peer.close(replacement); + return identity; + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + expect(fs.getLazyEntry("/lazy")).toBeNull(); + expect(fs.getLazyEntry("/moved")).not.toBeNull(); + await expect(fs.ensureMaterialized("/moved")).resolves.toBe(true); + const replacement = fs.open("/lazy", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(replacement, byte, null, 1)).toBe(1); + fs.close(replacement); + expect(byte[0]).toBe(9); + } finally { + createSpy.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("rejects generation-less lazy metadata from a live peer", () => { + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 999); + const [legacy] = fs.exportLazyEntries(); + delete legacy.generation; + delete legacy.dataSequence; + fs.unlink("/lazy"); + const replacement = fs.open("/lazy", O_CREAT | O_WRONLY, 0o644); + fs.close(replacement); + + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + expect(() => peer.importLazyEntries([legacy])).toThrow( + /requires inode generation and data sequence/, + ); + + expect(peer.getLazyEntry("/lazy")).toBeNull(); + expect(peer.stat("/lazy").size).toBe(0); + }); + + it("rejects sequence-less lazy metadata after same-inode content changes", () => { + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 999); + const [legacy] = fs.exportLazyEntries(); + delete legacy.dataSequence; + + const writer = fs.open("/lazy", O_WRONLY | O_TRUNC, 0o644); + fs.write(writer, new Uint8Array([9]), null, 1); + fs.close(writer); + + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + expect(() => peer.importLazyEntries([legacy])).toThrow( + /requires inode generation and data sequence/, + ); + + expect(peer.getLazyEntry("/lazy")).toBeNull(); + expect(peer.stat("/lazy").size).toBe(1); + }); + + it("does not apply lazy metadata after an inode slot is recycled", () => { + const fs = create(); + const oldIno = fs.registerLazyFile( + "/lazy", + "https://example.test/lazy", + 999, + ); + const exported = fs.exportLazyEntries(); + fs.unlink("/lazy"); + + const fd = fs.open("/replacement", O_CREAT | O_WRONLY, 0o644); + fs.close(fd); + expect(fs.stat("/replacement").ino).toBe(oldIno); + expect(fs.stat("/replacement").size).toBe(0); + expect(fs.getLazyEntry("/replacement")).toBeNull(); + + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + peer.importLazyEntries(exported); + expect(peer.stat("/replacement").size).toBe(0); + expect(peer.getLazyEntry("/replacement")).toBeNull(); + }); + + it("tracks lazy files across rename and hard-link lifecycle", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 3); + fs.rename("/lazy", "/renamed"); + fs.link("/renamed", "/alias"); + fs.unlink("/renamed"); + + expect(fs.getLazyEntry("/alias")).toMatchObject({ + path: "/alias", + size: 3, + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "3" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([1, 2, 3]).buffer), + } as unknown as Response); + try { + await expect(fs.ensureMaterialized("/alias")).resolves.toBe(true); + } finally { + globalThis.fetch = originalFetch; + } + expect(fs.stat("/alias").size).toBe(3); + }); + + it("retains a peer-created hard-link alias after unlink", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + peer.link("/lazy", "/alias"); + + fs.unlink("/lazy"); + expect(fs.getLazyEntry("/alias")).toMatchObject({ + path: "/alias", + paths: ["/alias"], + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + await expect(fs.ensureMaterialized("/alias")).resolves.toBe(true); + const fd = fs.open("/alias", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(7); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("retains a peer-created lazy alias when rename replaces its other name", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + fs.registerLazyFile("/destination", "https://example.test/lazy", 1); + peer.link("/destination", "/alias"); + const source = fs.open("/source", O_CREAT | O_WRONLY | O_TRUNC, 0o644); + fs.write(source, new Uint8Array([4]), null, 1); + fs.close(source); + + fs.rename("/source", "/destination"); + expect(fs.getLazyEntry("/alias")).toMatchObject({ path: "/alias" }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + await expect(fs.ensureMaterialized("/alias")).resolves.toBe(true); + const alias = fs.open("/alias", O_RDONLY, 0); + const aliasByte = new Uint8Array(1); + expect(fs.read(alias, aliasByte, null, 1)).toBe(1); + fs.close(alias); + expect(aliasByte[0]).toBe(7); + + const destination = fs.open("/destination", O_RDONLY, 0); + const destinationByte = new Uint8Array(1); + expect(fs.read(destination, destinationByte, null, 1)).toBe(1); + fs.close(destination); + expect(destinationByte[0]).toBe(4); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("uses the identity actually removed when unlink races a peer rename", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + const raw = ( + fs as unknown as { + fs: { unlink: (path: string) => unknown }; + } + ).fs; + const unlink = raw.unlink.bind(raw); + const unlinkSpy = vi.spyOn(raw, "unlink").mockImplementation((path) => { + peer.rename(path, "/moved"); + const replacement = peer.open(path, O_CREAT | O_WRONLY, 0o644); + peer.write(replacement, new Uint8Array([9]), null, 1); + peer.close(replacement); + return unlink(path); + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + fs.unlink("/lazy"); + expect(fs.getLazyEntry("/moved")).not.toBeNull(); + await expect(fs.ensureMaterialized("/moved")).resolves.toBe(true); + } finally { + unlinkSpy.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("uses the source actually renamed when a peer replaces the old path", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + const raw = ( + fs as unknown as { + fs: { rename: (oldPath: string, newPath: string) => unknown }; + } + ).fs; + const rename = raw.rename.bind(raw); + const renameSpy = vi + .spyOn(raw, "rename") + .mockImplementation((oldPath, newPath) => { + peer.rename(oldPath, "/moved"); + const replacement = peer.open(oldPath, O_CREAT | O_WRONLY, 0o644); + peer.write(replacement, new Uint8Array([9]), null, 1); + peer.close(replacement); + return rename(oldPath, newPath); + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + fs.rename("/lazy", "/renamed"); + expect(fs.getLazyEntry("/moved")).not.toBeNull(); + expect(fs.stat("/renamed").size).toBe(1); + await expect(fs.ensureMaterialized("/moved")).resolves.toBe(true); + } finally { + renameSpy.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("preserves every lazy hard-link name across instance transfer", () => { + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 3); + fs.link("/lazy", "/alias"); + + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + peer.importLazyEntries(fs.exportLazyEntries()); + peer.unlink("/lazy"); + + expect(peer.getLazyEntry("/alias")).toMatchObject({ + path: "/alias", + paths: ["/alias"], + size: 3, + }); + }); + + it("preserves ordinary and lazy hard-link identity while rebasing", () => { + const fs = create(); + const regular = fs.open("/regular", O_CREAT | O_WRONLY, 0o644); + fs.write(regular, new Uint8Array([1, 2, 3]), null, 3); + fs.close(regular); + fs.link("/regular", "/regular-alias"); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 3); + fs.link("/lazy", "/lazy-alias"); + + const rebased = fs.rebaseToNewFileSystem(8 * 1024 * 1024); + expect(rebased.stat("/regular").ino).toBe( + rebased.stat("/regular-alias").ino, + ); + expect(rebased.stat("/regular").nlink).toBe(2); + expect(rebased.stat("/lazy").ino).toBe(rebased.stat("/lazy-alias").ino); + expect(rebased.stat("/lazy").nlink).toBe(2); + expect(rebased.getLazyEntry("/lazy-alias")).toMatchObject({ + size: 3, + paths: expect.arrayContaining(["/lazy", "/lazy-alias"]), + }); + }); + + it("rebases from one coherent snapshot when a peer renames afterward", () => { + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 3); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + + // Inject the peer mutation immediately after SharedFS captures its bytes + // and identity map. The rebased result must consistently reflect that + // captured pre-rename state rather than walking the newer live tree. + const shared = ( + fs as unknown as { + fs: { snapshotState: () => unknown }; + } + ).fs; + const snapshotState = shared.snapshotState.bind(shared); + shared.snapshotState = () => { + const snapshot = snapshotState(); + peer.rename("/lazy", "/moved"); + return snapshot; + }; + + const rebased = fs.rebaseToNewFileSystem(8 * 1024 * 1024); + expect(peer.stat("/moved").size).toBe(0); + expect(() => peer.stat("/lazy")).toThrow(); + expect(rebased.stat("/lazy").size).toBe(3); + expect(() => rebased.stat("/moved")).toThrow(); + expect(rebased.getLazyEntry("/lazy")).toMatchObject({ + path: "/lazy", + size: 3, + }); + }); + + it("drops deferred backing after an explicit write through a hard link", async () => { + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 999); + fs.link("/lazy", "/alias"); + + const fd = fs.open("/alias", O_WRONLY | O_TRUNC, 0o644); + expect(fs.write(fd, new Uint8Array([0x5a]), null, 1)).toBe(1); + fs.close(fd); + + expect(fs.stat("/lazy").size).toBe(1); + expect(fs.getLazyEntry("/lazy")).toBeNull(); + expect(fs.getLazyEntry("/alias")).toBeNull(); + expect(fs.exportLazyEntries()).toEqual([]); + await expect(fs.ensureMaterialized("/alias")).resolves.toBe(false); + }); + + it("does not apply a delayed fetch to a replacement inode", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + let release!: (value: ArrayBuffer) => void; + const body = new Promise((resolve) => { + release = resolve; + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => body, + } as unknown as Response); + + try { + const pending = fs.ensureMaterialized("/lazy"); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + peer.unlink("/lazy"); + const replacement = peer.open("/lazy", O_CREAT | O_WRONLY, 0o644); + peer.write(replacement, new Uint8Array([9]), null, 1); + peer.close(replacement); + + release(new Uint8Array([1]).buffer); + await expect(pending).resolves.toBe(false); + + const fd = fs.open("/lazy", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(9); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("finishes one materialization call after a peer rename during fetch", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + let release!: (value: ArrayBuffer) => void; + const body = new Promise((resolve) => { + release = resolve; + }); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => body, + } as unknown as Response); + + try { + const pending = fs.ensureMaterialized("/lazy"); + await vi.waitFor(() => expect(globalThis.fetch).toHaveBeenCalled()); + peer.rename("/lazy", "/moved"); + release(new Uint8Array([7]).buffer); + + await expect(pending).resolves.toBe(true); + const fd = fs.open("/moved", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(7); + expect(fs.getLazyEntry("/moved")).toBeNull(); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("keeps an empty lazy stub retryable after ENOSPC", async () => { + const originalFetch = globalThis.fetch; + const fs = create(256 * 1024); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 4096); + const filler = fs.open("/filler", O_CREAT | O_WRONLY, 0o644); + const chunk = new Uint8Array(64 * 1024).fill(0xa5); + while (fs.write(filler, chunk, null, chunk.length) > 0) { + // Fill every allocatable block. + } + fs.close(filler); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "4096" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array(4096).fill(7).buffer), + } as unknown as Response); + + try { + await expect(fs.ensureMaterialized("/lazy")).rejects.toThrow( + /No space left/, + ); + expect(fs.getLazyEntry("/lazy")).not.toBeNull(); + expect(fs.stat("/lazy").size).toBe(4096); + + fs.unlink("/filler"); + await expect(fs.ensureMaterialized("/lazy")).resolves.toBe(true); + const fd = fs.open("/lazy", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(7); + } finally { + globalThis.fetch = originalFetch; + } + }); + + it("does not overwrite a same-inode write that wins after fetch", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([1]).buffer), + } as unknown as Response); + + type ReplaceArgs = [string, number, number, number, Uint8Array]; + const raw = ( + fs as unknown as { + fs: { replaceIfIdentity: (...args: ReplaceArgs) => boolean }; + } + ).fs; + const replace = raw.replaceIfIdentity.bind(raw); + const replaceSpy = vi + .spyOn(raw, "replaceIfIdentity") + .mockImplementation((...args: ReplaceArgs) => { + const fd = peer.open("/lazy", O_WRONLY | O_TRUNC, 0o644); + peer.write(fd, new Uint8Array([9]), null, 1); + peer.close(fd); + return replace(...args); + }); + + try { + await expect(fs.ensureMaterialized("/lazy")).resolves.toBe(false); + const fd = fs.open("/lazy", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(9); + } finally { + replaceSpy.mockRestore(); + globalThis.fetch = originalFetch; + } + }); + + it("materializes through a surviving path renamed by another instance", async () => { + const originalFetch = globalThis.fetch; + const fs = create(); + fs.registerLazyFile("/lazy", "https://example.test/lazy", 1); + const peer = MemoryFileSystem.fromExisting(fs.sharedBuffer); + fs.symlink("/moved", "/indirect"); + peer.rename("/lazy", "/moved"); + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + headers: new Headers({ "content-length": "1" }), + body: null, + arrayBuffer: () => Promise.resolve(new Uint8Array([7]).buffer), + } as unknown as Response); + + try { + await expect(fs.ensureMaterialized("/indirect")).resolves.toBe(true); + const fd = fs.open("/moved", O_RDONLY, 0); + const byte = new Uint8Array(1); + expect(fs.read(fd, byte, null, 1)).toBe(1); + fs.close(fd); + expect(byte[0]).toBe(7); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/host/test/signal-accept-livelock.test.ts b/host/test/signal-accept-livelock.test.ts index 98d85adc2d..7ab7822647 100644 --- a/host/test/signal-accept-livelock.test.ts +++ b/host/test/signal-accept-livelock.test.ts @@ -6,7 +6,7 @@ * `sendSignalToProcess` / `notifyPipeReadable` iterate `pendingPollRetries` * and, for each matching entry, delete it and synchronously `retrySyscall`. * A blocked `accept()` re-runs, returns EAGAIN, and re-registers under the - * SAME `channelOffset` key. A raw `for..of` over the live Map revisits the + * SAME channel key. A raw `for..of` over the live Map revisits the * re-inserted entry forever (JS Map iterators are not snapshots), spinning * the single kernel-worker thread and wedging the whole machine. * @@ -21,9 +21,10 @@ import { describe, expect, it, vi } from "vitest"; import { CentralizedKernelWorker } from "../src/kernel-worker"; const SIGCHLD = 17; +const SIGTERM = 15; function createSharedMemory(): WebAssembly.Memory { - return new WebAssembly.Memory({ initial: 1, maximum: 1, shared: true }); + return new WebAssembly.Memory({ initial: 2, maximum: 2, shared: true }); } function createChannel(pid: number, channelOffset: number): any { @@ -39,13 +40,18 @@ function createWorkerHarness(): any { exports: { kernel_handle_channel: () => 0, kernel_set_current_tid: () => {}, - kernel_is_signal_blocked: () => 0, // deliverable — proceed to the wake loops + kernel_pick_signal_target_tid: (pid: number) => pid, + kernel_thread_has_deliverable: () => 1, + kernel_get_process_exit_signal: () => -1, }, }, kernelMemory: createSharedMemory(), scratchOffset: 128, processes: new Map(), + channelTids: new Map(), pendingSleeps: new Map(), + pendingSignalWaits: new Map(), + signalWaitDeadlines: new Map(), pendingPollRetries: new Map(), pendingSelectRetries: new Map(), }); @@ -61,7 +67,7 @@ describe("signal delivery to a process blocked in accept()", () => { worker.processes.set(targetPid, { channels: [channel] }); - // The accept()'s parked-retry entry, keyed by channelOffset (matches + // The accept()'s parked-retry entry, keyed by exact channel (matches // handleBlockingRetry's registration for SYS_ACCEPT). const makeEntry = () => ({ timer: null, @@ -69,7 +75,7 @@ describe("signal delivery to a process blocked in accept()", () => { pipeIndices: [], acceptIndices: [7], }); - worker.pendingPollRetries.set(channelOffset, makeEntry()); + worker.pendingPollRetries.set(channel, makeEntry()); // Model accept() re-parking: every retry re-inserts the SAME key, exactly // as the real EAGAIN path does. Cap the re-insertions so a *regressed* @@ -79,7 +85,7 @@ describe("signal delivery to a process blocked in accept()", () => { worker.retrySyscall = vi.fn(() => { retryCount++; if (retryCount < 5000) { - worker.pendingPollRetries.set(channelOffset, makeEntry()); + worker.pendingPollRetries.set(channel, makeEntry()); } }); @@ -103,16 +109,209 @@ describe("signal delivery to a process blocked in accept()", () => { worker.scheduleWakeBlockedRetries = () => {}; const makeEntry = () => ({ timer: null, channel, pipeIndices: [pipeIdx], acceptIndices: [] }); - worker.pendingPollRetries.set(channelOffset, makeEntry()); + worker.pendingPollRetries.set(channel, makeEntry()); let retryCount = 0; worker.retrySyscall = vi.fn(() => { retryCount++; - if (retryCount < 5000) worker.pendingPollRetries.set(channelOffset, makeEntry()); + if (retryCount < 5000) worker.pendingPollRetries.set(channel, makeEntry()); }); worker.notifyPipeReadable(pipeIdx); expect(retryCount).toBe(1); }); + + it("interrupts only the sleeping thread selected for a shared signal", () => { + vi.useFakeTimers(); + try { + const worker = createWorkerHarness(); + const pid = 44; + const threadTid = 45; + const mainChannel = createChannel(pid, 0); + const threadChannel = createChannel(pid, 256); + const mainTimer = setTimeout(() => {}, 60_000); + const threadTimer = setTimeout(() => {}, 60_000); + const mainSleep = { + timer: mainTimer, + channel: mainChannel, + syscallNr: 1, + origArgs: [], + retVal: 0, + errVal: 0, + }; + const threadSleep = { + timer: threadTimer, + channel: threadChannel, + syscallNr: 1, + origArgs: [], + retVal: 0, + errVal: 0, + }; + worker.processes.set(pid, { channels: [mainChannel, threadChannel] }); + worker.channelTids.set(`${pid}:${threadChannel.channelOffset}`, threadTid); + worker.pendingSleeps.set(mainChannel, mainSleep); + worker.pendingSleeps.set(threadChannel, threadSleep); + worker.kernelInstance.exports.kernel_pick_signal_target_tid = vi.fn( + () => threadTid, + ); + // Model a caught SIGCHLD still pending for the selected pthread. + worker.completeSleepWithSignalCheck = vi.fn(); + + worker.sendSignalToProcess(pid, SIGCHLD); + + expect( + worker.kernelInstance.exports.kernel_pick_signal_target_tid, + ).toHaveBeenCalledWith(pid, SIGCHLD); + expect(worker.pendingSleeps.get(mainChannel)).toBe(mainSleep); + expect(worker.pendingSleeps.has(threadChannel)).toBe(false); + expect(worker.completeSleepWithSignalCheck).toHaveBeenCalledOnce(); + expect(worker.completeSleepWithSignalCheck).toHaveBeenCalledWith( + threadChannel, + threadSleep.syscallNr, + threadSleep.origArgs, + threadSleep.retVal, + threadSleep.errVal, + ); + } finally { + vi.useRealTimers(); + } + }); + + it("binds a sleeping pthread before dequeuing its pending signal", () => { + const worker = createWorkerHarness(); + const pid = 46; + const tid = 47; + const channel = createChannel(pid, 256); + const setCurrentTid = vi.fn(); + const dequeueSignal = vi.fn(() => 0); + worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); + worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; + worker.kernelInstance.exports.kernel_dequeue_signal = dequeueSignal; + worker.completeChannel = vi.fn(); + + worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); + + expect(setCurrentTid).toHaveBeenCalledWith(tid); + expect(dequeueSignal).toHaveBeenCalledWith(pid, expect.any(Number)); + expect(setCurrentTid.mock.invocationCallOrder[0]).toBeLessThan( + dequeueSignal.mock.invocationCallOrder[0], + ); + }); + + it("does not rebind an ordinary synchronous signal dequeue", () => { + const worker = createWorkerHarness(); + const pid = 48; + const channel = createChannel(pid, 0); + const setCurrentTid = vi.fn(); + worker.kernelInstance.exports.kernel_set_current_tid = setCurrentTid; + worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => 0); + + worker.dequeueSignalForDelivery(channel); + + expect(setCurrentTid).not.toHaveBeenCalled(); + }); + + it("does not resume a sleeping pthread after dequeue terminates it", () => { + const worker = createWorkerHarness(); + const pid = 49; + const tid = 50; + const channel = createChannel(pid, 256); + let exited = false; + worker.channelTids.set(`${pid}:${channel.channelOffset}`, tid); + worker.kernelInstance.exports.kernel_dequeue_signal = vi.fn(() => { + exited = true; + return 0; + }); + worker.getProcessExitSignal = vi.fn(() => exited ? SIGTERM : -1); + worker.handleProcessTerminated = vi.fn(); + worker.completeChannel = vi.fn(); + + worker.completeSleepWithSignalCheck(channel, 1, [], 0, 0); + + expect(worker.handleProcessTerminated).toHaveBeenCalledWith(channel); + expect(worker.completeChannel).not.toHaveBeenCalled(); + }); + + it("leaves a sleep parked when the kernel consumed an ignored signal", () => { + vi.useFakeTimers(); + try { + const worker = createWorkerHarness(); + const pid = 51; + const channel = createChannel(pid, 0); + const timer = setTimeout(() => {}, 60_000); + const sleep = { + timer, + channel, + syscallNr: 1, + origArgs: [], + retVal: 0, + errVal: 0, + }; + worker.processes.set(pid, { channels: [channel] }); + worker.pendingSleeps.set(channel, sleep); + worker.kernelInstance.exports.kernel_thread_has_deliverable = vi.fn( + () => 0, + ); + worker.completeSleepWithSignalCheck = vi.fn(); + worker.retrySyscall = vi.fn(); + worker.handlePselect6 = vi.fn(); + const pollEntry = { timer: null, channel }; + const selectEntry = { + timer: setTimeout(() => {}, 60_000), + channel, + origArgs: [], + syscallNr: 0, + }; + worker.pendingPollRetries.set(channel, pollEntry); + worker.pendingSelectRetries.set(channel, selectEntry); + + worker.sendSignalToProcess(pid, SIGCHLD); + + expect(worker.pendingSleeps.get(channel)).toBe(sleep); + expect(worker.pendingPollRetries.get(channel)).toBe(pollEntry); + expect(worker.pendingSelectRetries.get(channel)).toBe(selectEntry); + expect(worker.completeSleepWithSignalCheck).not.toHaveBeenCalled(); + expect(worker.retrySyscall).not.toHaveBeenCalled(); + expect(worker.handlePselect6).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + + it("reaps a default-terminated process without resuming its channel", () => { + const worker = createWorkerHarness(); + const pid = 52; + const pickSignalTarget = vi.fn(() => pid); + worker.kernelInstance.exports.kernel_pick_signal_target_tid = pickSignalTarget; + worker.reapKilledProcessesAfterSyscall = vi.fn(); + worker.getProcessExitSignal = vi.fn(() => SIGTERM); + + worker.sendSignalToProcess(pid, SIGTERM); + + expect(worker.reapKilledProcessesAfterSyscall).toHaveBeenCalledOnce(); + expect(pickSignalTarget).not.toHaveBeenCalled(); + }); + + it("does not wake blocked channels when queuing the signal traps", () => { + const worker = createWorkerHarness(); + const pid = 53; + const pickSignalTarget = vi.fn(() => pid); + const reaper = vi.fn(); + const error = vi.spyOn(console, "error").mockImplementation(() => {}); + try { + worker.kernelInstance.exports.kernel_handle_channel = () => { + throw new Error("synthetic kernel trap"); + }; + worker.kernelInstance.exports.kernel_pick_signal_target_tid = pickSignalTarget; + worker.reapKilledProcessesAfterSyscall = reaper; + + worker.sendSignalToProcess(pid, SIGTERM); + + expect(reaper).not.toHaveBeenCalled(); + expect(pickSignalTarget).not.toHaveBeenCalled(); + } finally { + error.mockRestore(); + } + }); }); diff --git a/host/test/socket-timeout-options.test.ts b/host/test/socket-timeout-options.test.ts new file mode 100644 index 0000000000..4b2ecdbb76 --- /dev/null +++ b/host/test/socket-timeout-options.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("socket timeout option guest ABI", () => { + it.each([".wasm", ".wasm64.wasm"])( + "sets and gets distinct receive and send timeouts (%s)", + async (suffix) => { + const result = await runCentralizedProgram({ + programPath: join( + __dirname, + `../../examples/socket_timeout_options_test${suffix}`, + ), + argv: ["socket-timeout-options-test"], + timeout: 15_000, + useDefaultRootfs: false, + }); + + expect(result.exitCode, result.stderr).toBe(0); + expect(result.stdout).toContain("SOCKET_TIMEOUT_OPTIONS_PASS"); + expect(result.stderr).toBe(""); + }, + ); +}); diff --git a/host/test/spawn-host-parity.test.ts b/host/test/spawn-host-parity.test.ts index 9310a4a76d..695c9da52d 100644 --- a/host/test/spawn-host-parity.test.ts +++ b/host/test/spawn-host-parity.test.ts @@ -36,6 +36,14 @@ const repoRoot = join(__dirname, "..", ".."); const nodeEntry = join(repoRoot, "host", "src", "node-kernel-worker-entry.ts"); const browserEntry = join(repoRoot, "host", "src", "browser-kernel-worker-entry.ts"); +function posixSpawnHandlerSource(src: string): string { + const start = src.indexOf("async function handlePosixSpawn("); + const end = src.indexOf("\nasync function handleClone(", start); + expect(start).toBeGreaterThanOrEqual(0); + expect(end).toBeGreaterThan(start); + return src.slice(start, end); +} + describe("spawn host parity", () => { it("Node kernel-worker-entry wires both onResolveSpawn and onSpawn", () => { const src = readFileSync(nodeEntry, "utf8"); @@ -51,6 +59,16 @@ describe("spawn host parity", () => { expect(src, `${nodeEntry} must wire onResolveSpawn: handlePosixSpawnResolve`).toMatch( /onResolveSpawn:\s*handlePosixSpawnResolve/, ); + const spawnHandler = posixSpawnHandlerSource(src); + expect(spawnHandler, `${nodeEntry} must accept posix_spawn parentage`).toMatch( + /handlePosixSpawn\(\s*parentPid:\s*number,\s*childPid:\s*number,/s, + ); + expect(spawnHandler, `${nodeEntry} must publish posix_spawn parentage`).toMatch( + /kind:\s*"spawn",\s*pid:\s*childPid,\s*ppid:\s*parentPid/, + ); + expect(spawnHandler, `${nodeEntry} must initialize the child with its real parent`).toMatch( + /ppid:\s*parentPid/, + ); }); it("Browser kernel-worker-entry wires both onResolveSpawn and onSpawn", () => { @@ -67,13 +85,23 @@ describe("spawn host parity", () => { expect(src, `${browserEntry} must wire onResolveSpawn (calling handlePosixSpawnResolve)`).toMatch( /onResolveSpawn:.*handlePosixSpawnResolve/s, ); + const spawnHandler = posixSpawnHandlerSource(src); + expect(spawnHandler, `${browserEntry} must accept posix_spawn parentage`).toMatch( + /handlePosixSpawn\(\s*parentPid:\s*number,\s*childPid:\s*number,/s, + ); + expect(spawnHandler, `${browserEntry} must publish posix_spawn parentage`).toMatch( + /kind:\s*"spawn",\s*pid:\s*childPid,\s*ppid:\s*parentPid/, + ); + expect(spawnHandler, `${browserEntry} must initialize the child with its real parent`).toMatch( + /ppid:\s*parentPid/, + ); }); it("CentralizedKernelCallbacks declares both onResolveSpawn and onSpawn", () => { // Ensures the host shared interface itself still surfaces both // callbacks — without these, neither entry would even type-check. const src = readFileSync(join(repoRoot, "host", "src", "kernel-worker.ts"), "utf8"); - expect(src).toMatch(/onSpawn\?:\s*\(/); + expect(src).toMatch(/onSpawn\?:\s*\(\s*parentPid:\s*number,\s*childPid:\s*number,/s); expect(src).toMatch(/onResolveSpawn\?:\s*\(/); }); }); diff --git a/host/test/symlink.test.ts b/host/test/symlink.test.ts index f848dd6772..a5674d40af 100644 --- a/host/test/symlink.test.ts +++ b/host/test/symlink.test.ts @@ -51,6 +51,22 @@ describe("symlink and lstat", () => { expect(st.mode & S_IFMT).toBe(S_IFLNK); }); + it("unlink removes a dangling symlink itself", () => { + const mfs = createMemfs(); + + const fd = mfs.open("/target.txt", O_WRONLY | O_CREAT | O_TRUNC, 0o644); + mfs.close(fd); + mfs.symlink("target.txt", "/link.txt"); + + mfs.unlink("/target.txt"); + expect(mfs.lstat("/link.txt").mode & S_IFMT).toBe(S_IFLNK); + + // POSIX unlink(2) unlinks the directory entry named by path. When path is + // a symlink, it removes the link inode and does not follow the target. + mfs.unlink("/link.txt"); + expect(() => mfs.lstat("/link.txt")).toThrow(); + }); + it("readlink returns the symlink target", () => { const mfs = createMemfs(); mfs.symlink("/some/path", "/mylink"); 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/host/test/tcp-backend.test.ts b/host/test/tcp-backend.test.ts new file mode 100644 index 0000000000..83f2fddb89 --- /dev/null +++ b/host/test/tcp-backend.test.ts @@ -0,0 +1,221 @@ +import { afterEach, describe, expect, it } from "vitest"; +import * as net from "node:net"; +import { TcpNetworkBackend } from "../src/networking/tcp-backend"; + +const LOOPBACK = new Uint8Array([127, 0, 0, 1]); +const POLLIN = 0x0001; +const MSG_PEEK = 0x0002; + +async function listenLoopback(): Promise<{ + server: net.Server; + port: number; + accepted: Promise<{ data: string; ended: boolean }>; +}> { + let resolveAccepted!: (value: { data: string; ended: boolean }) => void; + let rejectAccepted!: (error: unknown) => void; + const accepted = new Promise<{ data: string; ended: boolean }>((resolve, reject) => { + resolveAccepted = resolve; + rejectAccepted = reject; + }); + + const server = net.createServer((socket) => { + const chunks: Buffer[] = []; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.on("end", () => { + resolveAccepted({ data: Buffer.concat(chunks).toString("utf8"), ended: true }); + }); + socket.on("error", rejectAccepted); + }); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + return { + server, + port: (server.address() as net.AddressInfo).port, + accepted, + }; +} + +async function waitForConnected(backend: TcpNetworkBackend, handle: number): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + const status = backend.connectStatus(handle); + if (status === 0) return; + if (status > 0) throw new Error(`connect failed with errno ${status}`); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("connect timed out"); +} + +async function waitForReadable(backend: TcpNetworkBackend, handle: number): Promise { + const deadline = Date.now() + 2_000; + while (Date.now() < deadline) { + if ((backend.poll(handle, POLLIN) & POLLIN) !== 0) return; + await new Promise((resolve) => setTimeout(resolve, 5)); + } + throw new Error("readable data timed out"); +} + +describe("TcpNetworkBackend hostname parsing", () => { + it.each([ + ["2130706433", [127, 0, 0, 1]], + ["127.1", [127, 0, 0, 1]], + ["127.1.1", [127, 1, 0, 1]], + ["127.0.0.1", [127, 0, 0, 1]], + ])("resolves the decimal IPv4 form %s without DNS", (hostname, expected) => { + const backend = new TcpNetworkBackend(); + expect(Array.from(backend.getaddrinfo(hostname))).toEqual(expected); + }); + + it.each([ + "4294967296", + "1..2", + "1.2.3.256", + ".example.com", + "foo_bar.localhost", + `www.${"x".repeat(64)}.com`, + ])("rejects the invalid hostname %s before DNS", (hostname) => { + const backend = new TcpNetworkBackend(); + expect(() => backend.getaddrinfo(hostname)).toThrow("ENOENT"); + }); +}); + +describe("TcpNetworkBackend", () => { + const servers: net.Server[] = []; + + afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => new Promise((resolve) => { + server.close(() => resolve()); + }))); + }); + + it("closes TCP sockets with an orderly FIN after queued bytes", async () => { + const { server, port, accepted } = await listenLoopback(); + servers.push(server); + + const backend = new TcpNetworkBackend(); + backend.connect(7, LOOPBACK, port); + await waitForConnected(backend, 7); + + expect(backend.send(7, new TextEncoder().encode("hello"), 0)).toBe(5); + backend.close(7); + + await expect(accepted).resolves.toEqual({ data: "hello", ended: true }); + }); + + it("honors MSG_PEEK on real Node TCP sockets", async () => { + let acceptedSocket!: net.Socket; + let resolveAccepted!: () => void; + const accepted = new Promise((resolve) => { resolveAccepted = resolve; }); + const server = net.createServer((socket) => { + acceptedSocket = socket; + resolveAccepted(); + }); + servers.push(server); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const backend = new TcpNetworkBackend(); + backend.connect(10, LOOPBACK, (server.address() as net.AddressInfo).port); + await waitForConnected(backend, 10); + await accepted; + acceptedSocket.write("peek-data"); + await waitForReadable(backend, 10); + + expect(new TextDecoder().decode(backend.recv(10, 4, MSG_PEEK))).toBe("peek"); + expect(new TextDecoder().decode(backend.recv(10, 9, 0))).toBe("peek-data"); + + backend.close(10); + acceptedSocket.destroy(); + }); + + it("keeps the real writable half open after peer FIN", async () => { + let acceptedSocket!: net.Socket; + let resolveAccepted!: () => void; + let resolveAfterFin!: (value: string) => void; + const accepted = new Promise((resolve) => { resolveAccepted = resolve; }); + const afterFin = new Promise((resolve) => { resolveAfterFin = resolve; }); + const chunks: Buffer[] = []; + + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + acceptedSocket = socket; + socket.on("data", (chunk) => { + chunks.push(chunk); + if (Buffer.concat(chunks).toString("utf8").includes("after-fin-two")) { + resolveAfterFin(Buffer.concat(chunks).toString("utf8")); + } + }); + resolveAccepted(); + }); + servers.push(server); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const backend = new TcpNetworkBackend(); + backend.connect(8, LOOPBACK, (server.address() as net.AddressInfo).port); + await waitForConnected(backend, 8); + await accepted; + + expect(backend.send(8, new TextEncoder().encode("before-fin"), 0)).toBe(10); + acceptedSocket.end(); + + const deadline = Date.now() + 2_000; + for (;;) { + try { + const eof = backend.recv(8, 16, 0); + if (eof.length === 0) break; + } catch (error) { + if ((error as Error & { errno?: number }).errno !== 11) throw error; + } + if (Date.now() > deadline) throw new Error("recv EOF timed out"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(backend.send(8, new TextEncoder().encode("after-fin-one"), 0)).toBe(13); + expect(backend.send(8, new TextEncoder().encode("after-fin-two"), 0)).toBe(13); + await expect(afterFin).resolves.toBe("before-finafter-fin-oneafter-fin-two"); + + backend.close(8); + acceptedSocket.destroy(); + }); + + it("reports a real reset without fabricating a successful write", async () => { + let acceptedSocket!: net.Socket; + let resolveAccepted!: () => void; + const accepted = new Promise((resolve) => { resolveAccepted = resolve; }); + const server = net.createServer({ allowHalfOpen: true }, (socket) => { + acceptedSocket = socket; + resolveAccepted(); + }); + servers.push(server); + + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", () => resolve()); + }); + + const backend = new TcpNetworkBackend(); + backend.connect(9, LOOPBACK, (server.address() as net.AddressInfo).port); + await waitForConnected(backend, 9); + await accepted; + acceptedSocket.resetAndDestroy(); + + const deadline = Date.now() + 2_000; + while ((backend.poll(9, 0x0008) & 0x0008) === 0) { + if (Date.now() > deadline) throw new Error("reset observation timed out"); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + expect(() => backend.send(9, new TextEncoder().encode("after-reset"), 0)) + .toThrowError(/ECONNRESET/); + backend.close(9); + }); +}); diff --git a/host/test/teardown-reclaim.test.ts b/host/test/teardown-reclaim.test.ts index 2d144ba2c0..531ab4e7a7 100644 --- a/host/test/teardown-reclaim.test.ts +++ b/host/test/teardown-reclaim.test.ts @@ -20,6 +20,7 @@ import { readFileSync, existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { NodeKernelHost } from "../src/node-kernel-host"; +import { ABI_SYSCALLS } from "../src/generated/abi"; const __dirname = dirname(fileURLToPath(import.meta.url)); const blockForeverBinary = join(__dirname, "../../examples/block-forever.wasm"); @@ -49,17 +50,53 @@ describe.skipIf(!hasBinary)("teardown reclamation of Atomics.wait-blocked worker await host.init(); let pid = -1; - // Do NOT await its exit — it parks in nanosleep (Atomics.wait on its - // channel) and never exits on its own. - void host - .spawn(loadWasm(blockForeverBinary), ["block-forever"], { - onStarted: (p) => { pid = p; }, - }) - .catch(() => {}); + let resolveBlocked!: () => void; + let blockedTimeout!: ReturnType; + const blocked = new Promise((resolve, reject) => { + resolveBlocked = resolve; + blockedTimeout = setTimeout( + () => reject(new Error("block-forever did not enter a sleeping syscall")), + 5_000, + ); + }); + const sleepingPids = new Set(); + const unsubscribeSyscalls = host.subscribeSyscalls((event) => { + if ( + (event.nr === ABI_SYSCALLS.Nanosleep || + event.nr === ABI_SYSCALLS.ClockNanosleep) + ) { + sleepingPids.add(event.pid); + if (event.pid === pid) resolveBlocked(); + } + }); + + let readyForDestroy = false; + try { + // Do NOT await its exit — it parks in nanosleep (Atomics.wait on its + // channel) and never exits on its own. + void host + .spawn(loadWasm(blockForeverBinary), ["block-forever"], { + onStarted: (p) => { + pid = p; + if (sleepingPids.has(p)) resolveBlocked(); + }, + }) + .catch(() => {}); + + for (let i = 0; i < 200 && pid < 0; i++) await delay(20); + expect(pid).toBeGreaterThan(0); - for (let i = 0; i < 200 && pid < 0; i++) await delay(20); - expect(pid).toBeGreaterThan(0); - await delay(250); // ensure it has reached the blocking nanosleep + // Do not guess when the guest has reached Atomics.wait. Under CI load the + // process can be started but not yet have registered its blocking syscall; + // destroying it in that gap force-terminates it and does not exercise the + // cooperative wake path this test exists to protect. + await blocked; + readyForDestroy = true; + } finally { + clearTimeout(blockedTimeout); + unsubscribeSyscalls(); + if (!readyForDestroy) await host.destroy(); + } const t0 = Date.now(); await host.destroy(); diff --git a/host/test/thread-worker-disposition.test.ts b/host/test/thread-worker-disposition.test.ts index d667837f63..dc6515843c 100644 --- a/host/test/thread-worker-disposition.test.ts +++ b/host/test/thread-worker-disposition.test.ts @@ -1,5 +1,8 @@ import { describe, expect, it } from "vitest"; -import { threadWorkerFailureDisposition } from "../src/thread-worker-disposition"; +import { + removeThreadWorkerRegistryEntry, + threadWorkerFailureDisposition, +} from "../src/thread-worker-disposition"; import { signalExitStatus, SIGILL, SIGSEGV } from "../src/trap-signals"; describe("pthread worker failure disposition", () => { @@ -26,4 +29,17 @@ describe("pthread worker failure disposition", () => { kind: "host-thread-failure", }); }); + + it("retires the per-process registry after its final worker is reclaimed", () => { + const first = { tid: 11 }; + const second = { tid: 12 }; + const registry = new Map([[42, [first, second]]]); + + expect(removeThreadWorkerRegistryEntry(registry, 42, first)).toBe(true); + expect(registry.get(42)).toEqual([second]); + + expect(removeThreadWorkerRegistryEntry(registry, 42, second)).toBe(true); + expect(registry.has(42)).toBe(false); + expect(removeThreadWorkerRegistryEntry(registry, 42, second)).toBe(false); + }); }); diff --git a/host/test/time-provider.test.ts b/host/test/time-provider.test.ts new file mode 100644 index 0000000000..cf26053cf7 --- /dev/null +++ b/host/test/time-provider.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { NodePlatformIO } from "../src/platform/node"; +import { BrowserTimeProvider, NodeTimeProvider } from "../src/vfs/time"; + +function asNanoseconds(value: { sec: number; nsec: number }): bigint { + return BigInt(value.sec) * 1_000_000_000n + BigInt(value.nsec); +} + +describe.each([ + ["NodeTimeProvider", () => new NodeTimeProvider()], + ["BrowserTimeProvider", () => new BrowserTimeProvider()], + ["NodePlatformIO", () => new NodePlatformIO()], +] as const)("%s CLOCK_BOOTTIME", (_name, createProvider) => { + it("uses the same nondecreasing domain as CLOCK_MONOTONIC", () => { + const provider = createProvider(); + const monotonic = asNanoseconds(provider.clockGettime(1)); + const boottime = asNanoseconds(provider.clockGettime(7)); + + expect(boottime).toBeGreaterThanOrEqual(monotonic); + expect(boottime - monotonic).toBeLessThan(100_000_000n); + }); +}); diff --git a/host/test/unix-listener-exec.test.ts b/host/test/unix-listener-exec.test.ts new file mode 100644 index 0000000000..24b4553886 --- /dev/null +++ b/host/test/unix-listener-exec.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const program = join(__dirname, "../../examples/unix_listener_exec_test.wasm"); + +describe("AF_UNIX listener inheritance across fork and exec", () => { + it("accepts a pre-exec queued connection in the replacement worker", async () => { + const result = await runCentralizedProgram({ + programPath: program, + argv: ["/bin/unix-listener-exec"], + execPrograms: new Map([["/bin/unix-listener-exec", program]]), + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("UNIX_LISTENER_EXEC_PASS"); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }); +}); diff --git a/host/test/vfs-image.test.ts b/host/test/vfs-image.test.ts index 87b4ec1580..c028d86725 100644 --- a/host/test/vfs-image.test.ts +++ b/host/test/vfs-image.test.ts @@ -18,13 +18,22 @@ function createMemfs(): MemoryFileSystem { return MemoryFileSystem.create(sab); } -function writeFile(mfs: MemoryFileSystem, path: string, data: Uint8Array, mode = 0o644): void { +function writeFile( + mfs: MemoryFileSystem, + path: string, + data: Uint8Array, + mode = 0o644, +): void { // Ensure parent directories exist const parts = path.split("/").filter(Boolean); let current = ""; for (let i = 0; i < parts.length - 1; i++) { current += "/" + parts[i]; - try { mfs.mkdir(current, 0o755); } catch { /* exists */ } + try { + mfs.mkdir(current, 0o755); + } catch { + /* exists */ + } } const fd = mfs.open(path, O_WRONLY | O_CREAT | O_TRUNC, mode); mfs.write(fd, data, null, data.length); @@ -52,6 +61,28 @@ function readDir(mfs: MemoryFileSystem, path: string): string[] { return names.sort(); } +function stripStandaloneLazyIdentity(image: Uint8Array): Uint8Array { + const view = new DataView(image.buffer, image.byteOffset, image.byteLength); + const sabLen = view.getUint32(12, true); + const lazyOffset = 16 + sabLen; + const lazyLen = view.getUint32(lazyOffset, true); + const entries = JSON.parse( + new TextDecoder().decode( + image.subarray(lazyOffset + 4, lazyOffset + 4 + lazyLen), + ), + ) as Array<{ generation?: number; dataSequence?: number }>; + for (const entry of entries) { + delete entry.generation; + delete entry.dataSequence; + } + const lazyJson = new TextEncoder().encode(JSON.stringify(entries)); + const legacy = new Uint8Array(lazyOffset + 4 + lazyJson.byteLength); + legacy.set(image.subarray(0, lazyOffset)); + new DataView(legacy.buffer).setUint32(lazyOffset, lazyJson.byteLength, true); + legacy.set(lazyJson, lazyOffset + 4); + return legacy; +} + describe("VFS image save/restore", () => { describe("saveImage + fromImage round-trip", () => { it("preserves a single file", async () => { @@ -86,8 +117,12 @@ describe("VFS image save/restore", () => { const image = await mfs.saveImage(); const restored = MemoryFileSystem.fromImage(image); - expect(readFile(restored, "/usr/local/bin/tool")).toEqual(new Uint8Array([1, 2, 3])); - expect(readFile(restored, "/usr/local/lib/libfoo.so")).toEqual(new Uint8Array([4, 5, 6])); + expect(readFile(restored, "/usr/local/bin/tool")).toEqual( + new Uint8Array([1, 2, 3]), + ); + expect(readFile(restored, "/usr/local/lib/libfoo.so")).toEqual( + new Uint8Array([4, 5, 6]), + ); expect(readFile(restored, "/etc/config")).toEqual(new Uint8Array([7, 8])); // Verify directory listing @@ -149,11 +184,15 @@ describe("VFS image save/restore", () => { // Can write new files writeFile(restored, "/new.txt", new TextEncoder().encode("new")); - expect(new TextDecoder().decode(readFile(restored, "/new.txt"))).toBe("new"); + expect(new TextDecoder().decode(readFile(restored, "/new.txt"))).toBe( + "new", + ); // Can modify existing files writeFile(restored, "/existing.txt", new TextEncoder().encode("updated")); - expect(new TextDecoder().decode(readFile(restored, "/existing.txt"))).toBe("updated"); + expect( + new TextDecoder().decode(readFile(restored, "/existing.txt")), + ).toBe("updated"); }); }); @@ -161,13 +200,20 @@ describe("VFS image save/restore", () => { it("preserves lazy file metadata by default", async () => { const mfs = createMemfs(); writeFile(mfs, "/real.txt", new TextEncoder().encode("real content")); - mfs.registerLazyFile("/bin/lazy-tool", "http://example.com/tool.wasm", 12345, 0o755); + mfs.registerLazyFile( + "/bin/lazy-tool", + "http://example.com/tool.wasm", + 12345, + 0o755, + ); const image = await mfs.saveImage(); const restored = MemoryFileSystem.fromImage(image); // Real file content preserved - expect(new TextDecoder().decode(readFile(restored, "/real.txt"))).toBe("real content"); + expect(new TextDecoder().decode(readFile(restored, "/real.txt"))).toBe( + "real content", + ); // Lazy file metadata preserved — stat reports declared size const st = restored.stat("/bin/lazy-tool"); @@ -182,6 +228,27 @@ describe("VFS image save/restore", () => { expect(entries[0].size).toBe(12345); }); + it("restores identity-less lazy metadata only from its coherent legacy image", async () => { + const mfs = createMemfs(); + mfs.registerLazyFile( + "/bin/legacy-tool", + "http://example.com/legacy.wasm", + 54321, + 0o755, + ); + + const restored = MemoryFileSystem.fromImage( + stripStandaloneLazyIdentity(await mfs.saveImage()), + ); + + expect(restored.getLazyEntry("/bin/legacy-tool")).toMatchObject({ + path: "/bin/legacy-tool", + url: "http://example.com/legacy.wasm", + size: 54321, + }); + expect(restored.stat("/bin/legacy-tool").size).toBe(54321); + }); + it("preserves multiple lazy files", async () => { const mfs = createMemfs(); mfs.registerLazyFile("/bin/a", "http://example.com/a.wasm", 100); @@ -200,29 +267,43 @@ describe("VFS image save/restore", () => { ]); }); - it("materializeAll clears lazy entries from image", async () => { + it("reconciles a peer-renamed lazy path before saving", async () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const fsA = MemoryFileSystem.create(sab); + const fsB = MemoryFileSystem.fromExisting(sab); + const url = "http://example.com/lazy.wasm"; + + fsA.registerLazyFile("/lazy", url, 12_345, 0o755); + fsB.rename("/lazy", "/moved"); + + const restored = MemoryFileSystem.fromImage(await fsA.saveImage()); + expect(() => restored.stat("/lazy")).toThrow(/No such file/); + expect(restored.stat("/moved").size).toBe(12_345); + expect(restored.getLazyEntry("/moved")).toMatchObject({ + path: "/moved", + url, + size: 12_345, + }); + expect(restored.exportLazyEntries()).toHaveLength(1); + }); + + it("explicit content replacement clears lazy entries from the image", async () => { const mfs = createMemfs(); - // Register a lazy file and manually write content to simulate materialization - // (can't actually fetch in tests, so we simulate by writing content before save) - mfs.registerLazyFile("/bin/tool", "http://example.com/tool.wasm", 5000, 0o755); + mfs.registerLazyFile( + "/bin/tool", + "http://example.com/tool.wasm", + 5000, + 0o755, + ); - // Manually write content to the file (simulating what ensureMaterialized does) const toolContent = new Uint8Array(100); for (let i = 0; i < 100; i++) toolContent[i] = i; const fd = mfs.open("/bin/tool", O_WRONLY | O_CREAT | O_TRUNC, 0o755); mfs.write(fd, toolContent, null, toolContent.length); mfs.close(fd); - // Clear the lazy entry manually to simulate materialization - // (ensureMaterialized would do this via fetch + delete) - const entries = mfs.exportLazyEntries(); - expect(entries).toHaveLength(1); - - // Instead, test that a save without lazy entries works correctly - // by creating a fresh mfs with no lazy files - const mfs2 = createMemfs(); - writeFile(mfs2, "/bin/tool", toolContent, 0o755); - const image = await mfs2.saveImage(); + expect(mfs.exportLazyEntries()).toHaveLength(0); + const image = await mfs.saveImage(); const restored = MemoryFileSystem.fromImage(image); expect(restored.exportLazyEntries()).toHaveLength(0); @@ -234,7 +315,11 @@ describe("VFS image save/restore", () => { writeFile(mfs, "/test.txt", new TextEncoder().encode("test")); const image = await mfs.saveImage(); - const view = new DataView(image.buffer, image.byteOffset, image.byteLength); + const view = new DataView( + image.buffer, + image.byteOffset, + image.byteLength, + ); const flags = view.getUint32(8, true); expect(flags & 1).toBe(0); // no lazy flag }); @@ -244,7 +329,11 @@ describe("VFS image save/restore", () => { mfs.registerLazyFile("/bin/tool", "http://example.com/tool.wasm", 1000); const image = await mfs.saveImage(); - const view = new DataView(image.buffer, image.byteOffset, image.byteLength); + const view = new DataView( + image.buffer, + image.byteOffset, + image.byteLength, + ); const flags = view.getUint32(8, true); expect(flags & 1).toBe(1); // lazy flag set }); @@ -261,7 +350,11 @@ describe("VFS image save/restore", () => { }); const image = await mfs.saveImage(); - const view = new DataView(image.buffer, image.byteOffset, image.byteLength); + const view = new DataView( + image.buffer, + image.byteOffset, + image.byteLength, + ); const flags = view.getUint32(8, true); expect(flags & 4).toBe(4); // metadata flag set @@ -277,8 +370,9 @@ describe("VFS image save/restore", () => { kernelAbi: 11, createdBy: "vfs-image.test", }); - expect(new TextDecoder().decode(readFile(restored, "/bin/tool"))) - .toBe("\0asm"); + expect(new TextDecoder().decode(readFile(restored, "/bin/tool"))).toBe( + "\0asm", + ); }); it("preserves metadata when a restored image is saved again", async () => { @@ -304,7 +398,11 @@ describe("VFS image save/restore", () => { const restored = MemoryFileSystem.fromImage(image); const cleared = await restored.saveImage({ metadata: null }); - const view = new DataView(cleared.buffer, cleared.byteOffset, cleared.byteLength); + const view = new DataView( + cleared.buffer, + cleared.byteOffset, + cleared.byteLength, + ); const flags = view.getUint32(8, true); expect(flags & 4).toBe(0); expect(MemoryFileSystem.readImageMetadata(cleared)).toBeNull(); @@ -322,10 +420,12 @@ describe("VFS image save/restore", () => { version: 1, kernelAbi: ABI_VERSION, }); - expect(MemoryFileSystem.fromImage(compressed).getImageMetadata()).toEqual({ - version: 1, - kernelAbi: ABI_VERSION, - }); + expect(MemoryFileSystem.fromImage(compressed).getImageMetadata()).toEqual( + { + version: 1, + kernelAbi: ABI_VERSION, + }, + ); }); it("rejects malformed metadata declarations", async () => { @@ -345,11 +445,16 @@ describe("VFS image save/restore", () => { metadata: { version: 1, kernelAbi: olderAbi }, }); - expect(() => MemoryFileSystem.assertImageKernelAbi(image, olderAbi)).not.toThrow(); - expect(() => MemoryFileSystem.assertImageKernelAbi(image, ABI_VERSION, "test image")) - .toThrow(new RegExp( + expect(() => + MemoryFileSystem.assertImageKernelAbi(image, olderAbi), + ).not.toThrow(); + expect(() => + MemoryFileSystem.assertImageKernelAbi(image, ABI_VERSION, "test image"), + ).toThrow( + new RegExp( `test image requires kernel ABI ${olderAbi}.*running kernel is ABI ${ABI_VERSION}`, - )); + ), + ); }); }); @@ -369,20 +474,28 @@ describe("VFS image save/restore", () => { expect(compressed[3]).toBe(0xfd); const restored = MemoryFileSystem.fromImage(compressed); - expect(new TextDecoder().decode(readFile(restored, "/hello.txt"))).toBe("compressed!"); - expect(readFile(restored, "/etc/config")).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(new TextDecoder().decode(readFile(restored, "/hello.txt"))).toBe( + "compressed!", + ); + expect(readFile(restored, "/etc/config")).toEqual( + new Uint8Array([1, 2, 3, 4]), + ); }); it("fromImage with maxByteLength still works on zstd-wrapped image", async () => { const mfs = createMemfs(); writeFile(mfs, "/data.txt", new TextEncoder().encode("hi")); - const compressed = new Uint8Array(zstdCompressSync(await mfs.saveImage())); + const compressed = new Uint8Array( + zstdCompressSync(await mfs.saveImage()), + ); const restored = MemoryFileSystem.fromImage(compressed, { maxByteLength: 16 * 1024 * 1024, }); expect(restored.sharedBuffer.maxByteLength).toBe(16 * 1024 * 1024); - expect(new TextDecoder().decode(readFile(restored, "/data.txt"))).toBe("hi"); + expect(new TextDecoder().decode(readFile(restored, "/data.txt"))).toBe( + "hi", + ); }); it("saveImage writes a .vfs.zst that fromImage can restore", async () => { @@ -411,8 +524,9 @@ describe("VFS image save/restore", () => { const restored = MemoryFileSystem.fromImage(onDisk, { maxByteLength: 8 * 1024 * 1024, }); - expect(new TextDecoder().decode(readFile(restored, "/etc/hostname"))) - .toBe("wasmbox\n"); + expect( + new TextDecoder().decode(readFile(restored, "/etc/hostname")), + ).toBe("wasmbox\n"); const restoredBig = readFile(restored, "/usr/lib/libfoo.so"); expect(restoredBig.length).toBe(big.length); expect(restoredBig).toEqual(big); @@ -438,7 +552,9 @@ describe("VFS image save/restore", () => { it("rejects image with bad magic", () => { const bad = new Uint8Array(32); new DataView(bad.buffer).setUint32(0, 0xdeadbeef, true); - expect(() => MemoryFileSystem.fromImage(bad)).toThrow("Bad VFS image magic"); + expect(() => MemoryFileSystem.fromImage(bad)).toThrow( + "Bad VFS image magic", + ); }); it("rejects image with wrong version", () => { @@ -446,7 +562,9 @@ describe("VFS image save/restore", () => { const view = new DataView(bad.buffer); view.setUint32(0, 0x56465349, true); // VFSI magic view.setUint32(4, 99, true); // bad version - expect(() => MemoryFileSystem.fromImage(bad)).toThrow("Unsupported VFS image version"); + expect(() => MemoryFileSystem.fromImage(bad)).toThrow( + "Unsupported VFS image version", + ); }); it("rejects truncated image", () => { @@ -460,13 +578,19 @@ describe("VFS image save/restore", () => { }); it("rejects image that is too small", () => { - expect(() => MemoryFileSystem.fromImage(new Uint8Array(4))).toThrow("too small"); + expect(() => MemoryFileSystem.fromImage(new Uint8Array(4))).toThrow( + "too small", + ); }); it("image has correct magic and version", async () => { const mfs = createMemfs(); const image = await mfs.saveImage(); - const view = new DataView(image.buffer, image.byteOffset, image.byteLength); + const view = new DataView( + image.buffer, + image.byteOffset, + image.byteLength, + ); expect(view.getUint32(0, true)).toBe(0x56465349); // "VFSI" expect(view.getUint32(4, true)).toBe(1); // version 1 }); @@ -504,7 +628,9 @@ describe("VFS image save/restore", () => { const image = await mfs.saveImage(); const maxBytes = 16 * 1024 * 1024; - const restored = MemoryFileSystem.fromImage(image, { maxByteLength: maxBytes }); + const restored = MemoryFileSystem.fromImage(image, { + maxByteLength: maxBytes, + }); const buf = restored.sharedBuffer; expect(buf).toBeInstanceOf(SharedArrayBuffer); // The SAB should have maxByteLength set (growable) @@ -558,12 +684,18 @@ describe("VFS image save/restore", () => { writeFile(mfs, "/original.txt", new TextEncoder().encode("data")); const image = await mfs.saveImage(); - const restored = MemoryFileSystem.fromImage(image, { maxByteLength: 32 * 1024 * 1024 }); + const restored = MemoryFileSystem.fromImage(image, { + maxByteLength: 32 * 1024 * 1024, + }); // Can read existing files - expect(new TextDecoder().decode(readFile(restored, "/original.txt"))).toBe("data"); + expect( + new TextDecoder().decode(readFile(restored, "/original.txt")), + ).toBe("data"); // Can write new files writeFile(restored, "/new.txt", new TextEncoder().encode("new data")); - expect(new TextDecoder().decode(readFile(restored, "/new.txt"))).toBe("new data"); + expect(new TextDecoder().decode(readFile(restored, "/new.txt"))).toBe( + "new data", + ); }); it("without maxByteLength creates a non-growable SAB", async () => { @@ -577,6 +709,22 @@ describe("VFS image save/restore", () => { }); describe("rebaseToNewFileSystem", () => { + it("grows the initial buffer to fit metadata for a 2 GiB image", () => { + const source = createMemfs(); + writeFile(source, "/data.txt", new TextEncoder().encode("base")); + + const maxBytes = 2 * 1024 * 1024 * 1024; + const rebased = source.rebaseToNewFileSystem(maxBytes); + const stats = rebased.statfs("/"); + + expect(rebased.sharedBuffer.byteLength).toBeGreaterThan(16 * 1024 * 1024); + expect(rebased.sharedBuffer.maxByteLength).toBe(maxBytes); + expect(stats.blocks * stats.bsize).toBe(maxBytes); + expect(new TextDecoder().decode(readFile(rebased, "/data.txt"))).toBe( + "base", + ); + }); + it("raises the filesystem max beyond the source image superblock cap", async () => { const initialBytes = 1 * 1024 * 1024; const imageMaxBytes = 2 * 1024 * 1024; @@ -591,30 +739,43 @@ describe("VFS image save/restore", () => { const restored = MemoryFileSystem.fromImage(image, { maxByteLength: rebaseMaxBytes, }); - expect(restored.statfs("/").blocks * restored.statfs("/").bsize).toBe(imageMaxBytes); + expect(restored.statfs("/").blocks * restored.statfs("/").bsize).toBe( + imageMaxBytes, + ); const rebased = restored.rebaseToNewFileSystem(rebaseMaxBytes); const stats = rebased.statfs("/"); expect(rebased.sharedBuffer.maxByteLength).toBe(rebaseMaxBytes); expect(stats.blocks * stats.bsize).toBe(rebaseMaxBytes); - expect(new TextDecoder().decode(readFile(rebased, "/data.txt"))).toBe("base"); + expect(new TextDecoder().decode(readFile(rebased, "/data.txt"))).toBe( + "base", + ); const rebasedImage = await rebased.saveImage(); const rerestored = MemoryFileSystem.fromImage(rebasedImage, { maxByteLength: rebaseMaxBytes, }); const rerestoredStats = rerestored.statfs("/"); - expect(rerestoredStats.blocks * rerestoredStats.bsize).toBe(rebaseMaxBytes); + expect(rerestoredStats.blocks * rerestoredStats.bsize).toBe( + rebaseMaxBytes, + ); }); it("preserves lazy file metadata without materializing stubs", async () => { const mfs = createMemfs(); writeFile(mfs, "/real.txt", new TextEncoder().encode("real")); - mfs.registerLazyFile("/bin/lazy-tool", "http://example.com/tool.wasm", 5_000_000, 0o755); + mfs.registerLazyFile( + "/bin/lazy-tool", + "http://example.com/tool.wasm", + 5_000_000, + 0o755, + ); const rebased = mfs.rebaseToNewFileSystem(16 * 1024 * 1024); - expect(new TextDecoder().decode(readFile(rebased, "/real.txt"))).toBe("real"); + expect(new TextDecoder().decode(readFile(rebased, "/real.txt"))).toBe( + "real", + ); expect(rebased.stat("/bin/lazy-tool").size).toBe(5_000_000); expect(rebased.stat("/bin/lazy-tool").mode & 0o777).toBe(0o755); expect(rebased.exportLazyEntries()).toMatchObject([ @@ -644,7 +805,9 @@ describe("VFS image save/restore", () => { writeFile(mfs, "/test.txt", new TextEncoder().encode("modified")); // Restored should still have original content - expect(new TextDecoder().decode(readFile(restored, "/test.txt"))).toBe("original"); + expect(new TextDecoder().decode(readFile(restored, "/test.txt"))).toBe( + "original", + ); }); it("modifications to restored filesystem don't affect original", async () => { @@ -659,7 +822,9 @@ describe("VFS image save/restore", () => { writeFile(restored, "/new.txt", new TextEncoder().encode("new")); // Original should be untouched - expect(new TextDecoder().decode(readFile(mfs, "/test.txt"))).toBe("original"); + expect(new TextDecoder().decode(readFile(mfs, "/test.txt"))).toBe( + "original", + ); expect(() => mfs.stat("/new.txt")).toThrow(); }); @@ -676,7 +841,9 @@ describe("VFS image save/restore", () => { expect(new TextDecoder().decode(readFile(r1, "/data.txt"))).toBe("r1"); expect(new TextDecoder().decode(readFile(r2, "/data.txt"))).toBe("r2"); - expect(new TextDecoder().decode(readFile(mfs, "/data.txt"))).toBe("shared"); + expect(new TextDecoder().decode(readFile(mfs, "/data.txt"))).toBe( + "shared", + ); }); }); }); diff --git a/host/test/vfs.test.ts b/host/test/vfs.test.ts index 12288acde7..c0a7080953 100644 --- a/host/test/vfs.test.ts +++ b/host/test/vfs.test.ts @@ -1,5 +1,11 @@ -import { describe, it, expect, beforeEach, afterEach } from "vitest"; -import { mkdtempSync, writeFileSync, readFileSync, rmSync } from "node:fs"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { + mkdirSync, + mkdtempSync, + writeFileSync, + readFileSync, + rmSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { VirtualPlatformIO } from "../src/vfs/vfs"; @@ -78,6 +84,10 @@ function createMockBackend(): FileSystemBackend & { calls: string[] } { calls.push(`fstat:${h}`); return { ...dummyStat }; }, + fpathconf: (h, name) => { + calls.push(`fpathconf:${h}:${name}`); + return 4096; + }, ftruncate: (h, l) => { calls.push(`ftruncate:${h}`); }, @@ -102,6 +112,10 @@ function createMockBackend(): FileSystemBackend & { calls: string[] } { calls.push(`statfs:${p}`); return { ...dummyStatfs }; }, + pathconf: (p, name) => { + calls.push(`pathconf:${p}:${name}`); + return 4096; + }, mkdir: (p, m) => { calls.push(`mkdir:${p}`); }, @@ -130,6 +144,9 @@ function createMockBackend(): FileSystemBackend & { calls: string[] } { chown: (p, u, g) => { calls.push(`chown:${p}`); }, + lchown: (p, u, g) => { + calls.push(`lchown:${p}`); + }, access: (p, m) => { calls.push(`access:${p}`); }, @@ -180,6 +197,23 @@ describe("VirtualPlatformIO mount resolution", () => { expect(root.calls).not.toContain("stat:/tmp/foo"); }); + it("routes lchown by the final link pathname rather than its target", () => { + const root = createMockBackend(); + const tmp = createMockBackend(); + const vfs = new VirtualPlatformIO( + [ + { mountPoint: "/", backend: root }, + { mountPoint: "/tmp", backend: tmp }, + ], + new NodeTimeProvider(), + ); + + vfs.lchown("/tmp/link-to-root", 123, 456); + + expect(tmp.calls).toContain("lchown:/link-to-root"); + expect(root.calls).not.toContain("lchown:/tmp/link-to-root"); + }); + it("does not route /home/foo to /tmp mount", () => { const root = createMockBackend(); const tmp = createMockBackend(); @@ -238,6 +272,95 @@ describe("VirtualPlatformIO mount resolution", () => { }); }); +describe("VirtualPlatformIO file identity", () => { + it("qualifies colliding inode numbers by backend", () => { + const root = createMockBackend(); + const first = createMockBackend(); + const second = createMockBackend(); + const vfs = new VirtualPlatformIO( + [ + { mountPoint: "/", backend: root }, + { mountPoint: "/first", backend: first }, + { mountPoint: "/second", backend: second }, + ], + new NodeTimeProvider(), + ); + + expect(vfs.fileIdentity("/first/file", 0n, 2n)).not.toBe( + vfs.fileIdentity("/second/file", 0n, 2n), + ); + }); + + it("uses one namespace when the same backend is mounted twice", () => { + const root = createMockBackend(); + const shared = createMockBackend(); + const vfs = new VirtualPlatformIO( + [ + { mountPoint: "/", backend: root }, + { mountPoint: "/one", backend: shared }, + { mountPoint: "/two", backend: shared }, + ], + new NodeTimeProvider(), + ); + + expect(vfs.fileIdentity("/one/alias", 0n, 7n)).toBe( + vfs.fileIdentity("/two/alias", 0n, 7n), + ); + }); + + it("rejects a backend that supplies no stable inode", () => { + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend: createMockBackend() }], + new NodeTimeProvider(), + ); + + expect(vfs.fileIdentity("/file", 0n, 0n)).toBeNull(); + }); + + it("derives identity from live handles after unlink and rename", () => { + const backend = MemoryFileSystem.create( + new SharedArrayBuffer(4 * 1024 * 1024), + ); + const vfs = new VirtualPlatformIO( + [{ mountPoint: "/", backend }], + new NodeTimeProvider(), + ); + + const unlinked = vfs.open("/unlinked", O_CREAT | O_RDWR, 0o600); + const unlinkedStat = vfs.fstat(unlinked); + const unlinkedIdentity = vfs.fileHandleIdentity( + unlinked, + BigInt(unlinkedStat.dev), + BigInt(unlinkedStat.ino), + ); + expect(unlinkedIdentity).not.toBeNull(); + vfs.unlink("/unlinked"); + expect(vfs.fileHandleIdentity( + unlinked, + BigInt(unlinkedStat.dev), + BigInt(unlinkedStat.ino), + )).toBe(unlinkedIdentity); + + const renamed = vfs.open("/before", O_CREAT | O_RDWR, 0o600); + const renamedStat = vfs.fstat(renamed); + const renamedIdentity = vfs.fileHandleIdentity( + renamed, + BigInt(renamedStat.dev), + BigInt(renamedStat.ino), + ); + expect(renamedIdentity).not.toBeNull(); + vfs.rename("/before", "/after"); + expect(vfs.fileHandleIdentity( + renamed, + BigInt(renamedStat.dev), + BigInt(renamedStat.ino), + )).toBe(renamedIdentity); + + vfs.close(unlinked); + vfs.close(renamed); + }); +}); + // --------------------------------------------------------------------------- // 2. Handle mapping tests // --------------------------------------------------------------------------- @@ -351,13 +474,24 @@ describe("VirtualPlatformIO cross-mount rename (EXDEV)", () => { describe("HostFileSystem path traversal", () => { it("rejects paths that escape rootPath", () => { - const hfs = new HostFileSystem("/tmp/sandbox"); - expect(() => hfs.stat("/../../../etc/passwd")).toThrow("EACCES"); + const root = mkdtempSync(join(tmpdir(), "kandelo-host-fs-traversal-")); + try { + const hfs = new HostFileSystem(root); + expect(() => hfs.stat("/../../../etc/passwd")).toThrow("EACCES"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); it("rejects paths with embedded .. sequences", () => { - const hfs = new HostFileSystem("/tmp/sandbox"); - expect(() => hfs.stat("/subdir/../../etc/passwd")).toThrow("EACCES"); + const root = mkdtempSync(join(tmpdir(), "kandelo-host-fs-traversal-")); + try { + mkdirSync(join(root, "subdir")); + const hfs = new HostFileSystem(root); + expect(() => hfs.stat("/subdir/../../etc/passwd")).toThrow("EACCES"); + } finally { + rmSync(root, { recursive: true, force: true }); + } }); }); @@ -463,6 +597,90 @@ describe("MemoryFileSystem", () => { expect(entries).toContain("file.txt"); }); + it("reports raw inode numbers that remain representable after inode reuse", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_RDWR = 0x0002, + O_TRUNC = 0x0200; + + // SharedFS tracks an internal generation counter for reused inode slots. + // POSIX st_ino does not need to include that generation, and exposing it + // can overflow 32-bit guest language APIs while tools like ls(1) print the + // full kernel value. + for (let i = 0; i < 2_100; i++) { + const fd = mfs.open("/reuse.txt", O_CREAT | O_RDWR | O_TRUNC, 0o644); + mfs.close(fd); + mfs.unlink("/reuse.txt"); + } + + const fd = mfs.open("/reuse.txt", O_CREAT | O_RDWR | O_TRUNC, 0o644); + const stat = mfs.fstat(fd); + expect(stat.ino).toBeGreaterThan(0); + expect(stat.ino).toBeLessThanOrEqual(0x7fffffff); + + const dh = mfs.opendir("/"); + let entry; + let dirIno: number | null = null; + while ((entry = mfs.readdir(dh)) !== null) { + if (entry.name === "reuse.txt") { + dirIno = entry.ino; + break; + } + } + mfs.closedir(dh); + expect(dirIno).toBe(stat.ino); + mfs.close(fd); + }); + + it("keeps large-directory indexes coherent across SharedFS instances", () => { + const sab = new SharedArrayBuffer(8 * 1024 * 1024); + const first = MemoryFileSystem.create(sab); + const second = MemoryFileSystem.fromExisting(sab); + first.mkdir("/bulk", 0o755); + + const names: string[] = []; + for (let i = 0; i < 340; i++) { + const name = `/bulk/${String(i).padStart(4, "0")}-${"x".repeat(180)}`; + names.push(name); + const fd = first.open(name, O_CREAT | O_RDWR, 0o644); + first.close(fd); + } + + // Populate the first mount's index, then reuse a deleted slot through a + // second mount without changing the directory's byte size. + expect(first.stat(names.at(-1)!).mode & 0xf000).toBe(0x8000); + second.unlink(names[100]); + const replacement = `/bulk/repl-${"y".repeat(180)}`; + const replacementFd = second.open(replacement, O_CREAT | O_RDWR, 0o644); + second.close(replacementFd); + + expect(first.stat(replacement).mode & 0xf000).toBe(0x8000); + expect(() => first.stat(names[100])).toThrow(/No such file/); + }); + + it("honors O_CREAT|O_EXCL by failing when the final path already exists", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_WRONLY = 0x0001, + O_CREAT = 0x0040, + O_EXCL = 0x0080; + + const fd = mfs.open("/exclusive.txt", O_WRONLY | O_CREAT | O_EXCL, 0o600); + mfs.close(fd); + + expect(() => + mfs.open("/exclusive.txt", O_WRONLY | O_CREAT | O_EXCL, 0o600), + ).toThrow(/File exists/); + + // POSIX open(O_CREAT|O_EXCL) must fail with EEXIST when the final path is + // a symbolic link, even if the symlink points at an existing regular file. + mfs.symlink("/exclusive.txt", "/exclusive-link.txt"); + expect(() => + mfs.open("/exclusive-link.txt", O_WRONLY | O_CREAT | O_EXCL, 0o600), + ).toThrow(/File exists/); + }); + it("stat returns correct size after writing", () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const mfs = MemoryFileSystem.create(sab); @@ -477,6 +695,37 @@ describe("MemoryFileSystem", () => { mfs.close(fd); }); + it("updates mtime and ctime after file writes and truncates", () => { + const now = vi.spyOn(Date, "now"); + try { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + now.mockReturnValue(1_000); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_RDWR = 0x0002, + O_TRUNC = 0x0200; + const fd = mfs.open("/timestamps.txt", O_CREAT | O_RDWR | O_TRUNC, 0o644); + const initial = mfs.fstat(fd); + + now.mockReturnValue(5_000); + mfs.write(fd, new TextEncoder().encode("abc"), null, 3); + const afterWrite = mfs.fstat(fd); + expect(afterWrite.mtimeMs).toBe(5_000); + expect(afterWrite.ctimeMs).toBe(5_000); + expect(afterWrite.mtimeMs).toBeGreaterThan(initial.mtimeMs); + + now.mockReturnValue(9_000); + mfs.ftruncate(fd, 1); + const afterTruncate = mfs.fstat(fd); + expect(afterTruncate.mtimeMs).toBe(9_000); + expect(afterTruncate.ctimeMs).toBe(9_000); + expect(afterTruncate.mtimeMs).toBeGreaterThan(afterWrite.mtimeMs); + mfs.close(fd); + } finally { + now.mockRestore(); + } + }); + it("unlink removes a file", () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const mfs = MemoryFileSystem.create(sab); @@ -488,6 +737,140 @@ describe("MemoryFileSystem", () => { expect(() => mfs.stat("/todelete.txt")).toThrow(); }); + it("rejects unlink paths with a trailing slash on non-directories", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_WRONLY = 0x0001; + + const fd = mfs.open("/file.txt", O_CREAT | O_WRONLY, 0o644); + mfs.close(fd); + mfs.symlink("/file.txt", "/link.txt"); + + expect(() => mfs.unlink("/file.txt/")).toThrow(/Not a directory/); + expect(() => mfs.unlink("/link.txt/")).toThrow(/Not a directory/); + expect(mfs.stat("/file.txt").mode & 0xf000).toBe(0x8000); + expect(mfs.readlink("/link.txt")).toBe("/file.txt"); + }); + + it("rejects rename source paths that require a non-directory to be a directory", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_WRONLY = 0x0001; + + const fd = mfs.open("/file.txt", O_CREAT | O_WRONLY, 0o644); + mfs.close(fd); + + expect(() => mfs.rename("/file.txt/", "/renamed.txt")).toThrow( + /Not a directory/, + ); + expect(mfs.stat("/file.txt").size).toBe(0); + expect(() => mfs.stat("/renamed.txt")).toThrow(/No such file/); + }); + + it("preserves POSIX type checks when renaming directories onto existing paths", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_WRONLY = 0x0001; + + mfs.mkdir("/dir", 0o755); + const fd = mfs.open("/file.txt", O_CREAT | O_WRONLY, 0o644); + mfs.close(fd); + mfs.symlink("/file.txt", "/link.txt"); + + expect(() => mfs.rename("/dir", "/file.txt")).toThrow(/Not a directory/); + expect(() => mfs.rename("/dir", "/link.txt")).toThrow(/Not a directory/); + + expect(mfs.stat("/dir").mode & 0xf000).toBe(0x4000); + expect(mfs.stat("/file.txt").mode & 0xf000).toBe(0x8000); + expect(mfs.readlink("/link.txt")).toBe("/file.txt"); + }); + + it("renames directories over empty directories and updates dot-dot", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_WRONLY = 0x0001; + + mfs.mkdir("/old-parent", 0o755); + mfs.mkdir("/new-parent", 0o755); + mfs.mkdir("/old-parent/child", 0o755); + const siblingFd = mfs.open( + "/new-parent/sibling.txt", + O_CREAT | O_WRONLY, + 0o644, + ); + mfs.close(siblingFd); + + mfs.rename("/old-parent/child", "/new-parent/child"); + expect(mfs.stat("/new-parent/child/../sibling.txt").mode & 0xf000).toBe( + 0x8000, + ); + + mfs.mkdir("/empty-dest", 0o755); + mfs.rename("/new-parent/child", "/empty-dest"); + expect(mfs.stat("/empty-dest").mode & 0xf000).toBe(0x4000); + expect(() => mfs.stat("/new-parent/child")).toThrow(/No such file/); + }); + + it("rejects rename and rmdir operands ending in dot or dot-dot", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + mfs.mkdir("/dir", 0o755); + mfs.mkdir("/dir/child", 0o755); + + expect(() => mfs.rename("/dir/.", "/moved")).toThrow(/Invalid argument/); + expect(() => mfs.rename("/dir/child", "/dir/..")).toThrow(/Invalid argument/); + expect(() => mfs.rmdir("/dir/.")).toThrow(/Invalid argument/); + expect(() => mfs.rmdir("/dir/child/..")).toThrow(/Invalid argument/); + expect(mfs.stat("/dir/child").mode & 0xf000).toBe(0x4000); + }); + + it("chmod and fchmod preserve the inode file type", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const fd = mfs.open("/regular", O_CREAT | O_RDWR, 0o644); + + mfs.chmod("/regular", 0o040755); + expect(mfs.stat("/regular").mode & 0xf000).toBe(0x8000); + mfs.fchmod(fd, 0o040700); + expect(mfs.fstat(fd).mode & 0xf000).toBe(0x8000); + mfs.close(fd); + }); + + it("keeps an unlinked open file alive until close", () => { + const sab = new SharedArrayBuffer(4 * 1024 * 1024); + const mfs = MemoryFileSystem.create(sab); + const O_CREAT = 0x0040, + O_RDWR = 0x0002, + O_TRUNC = 0x0200; + + const oldFd = mfs.open("/open.txt", O_CREAT | O_RDWR | O_TRUNC, 0o644); + const oldData = new TextEncoder().encode("old"); + mfs.write(oldFd, oldData, null, oldData.length); + mfs.unlink("/open.txt"); + expect(() => mfs.stat("/open.txt")).toThrow(); + + const newFd = mfs.open("/open.txt", O_CREAT | O_RDWR | O_TRUNC, 0o644); + const newData = new TextEncoder().encode("newer"); + mfs.write(newFd, newData, null, newData.length); + + mfs.seek(oldFd, 0, 0); + const oldBuf = new Uint8Array(8); + const oldRead = mfs.read(oldFd, oldBuf, null, oldBuf.length); + expect(new TextDecoder().decode(oldBuf.subarray(0, oldRead))).toBe("old"); + + mfs.seek(newFd, 0, 0); + const newBuf = new Uint8Array(8); + const newRead = mfs.read(newFd, newBuf, null, newBuf.length); + expect(new TextDecoder().decode(newBuf.subarray(0, newRead))).toBe("newer"); + + mfs.close(oldFd); + mfs.close(newFd); + }); + it("ftruncate changes file size", () => { const sab = new SharedArrayBuffer(4 * 1024 * 1024); const mfs = MemoryFileSystem.create(sab); @@ -689,4 +1072,14 @@ describe("NodeTimeProvider", () => { const ns2 = BigInt(t2.sec) * 1_000_000_000n + BigInt(t2.nsec); expect(ns2).toBeGreaterThanOrEqual(ns1); }); + + it("treats CLOCK_BOOTTIME as monotonic-equivalent", () => { + const tp = new NodeTimeProvider(); + const monotonic = tp.clockGettime(1); + const boottime = tp.clockGettime(7); + const monotonicNs = BigInt(monotonic.sec) * 1_000_000_000n + BigInt(monotonic.nsec); + const boottimeNs = BigInt(boottime.sec) * 1_000_000_000n + BigInt(boottime.nsec); + expect(boottimeNs).toBeGreaterThanOrEqual(monotonicNs); + expect(boottimeNs - monotonicNs).toBeLessThan(100_000_000n); + }); }); diff --git a/host/test/vfs/host-fs-path-resolution.test.ts b/host/test/vfs/host-fs-path-resolution.test.ts new file mode 100644 index 0000000000..2da92fc605 --- /dev/null +++ b/host/test/vfs/host-fs-path-resolution.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdirSync, + mkdtempSync, + renameSync, + rmSync, + statSync, + symlinkSync, + utimesSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { HostFileSystem } from "../../src/vfs/host-fs"; + +const O_WRONLY = 0o1; +const O_CREAT = 0o100; +const O_EXCL = 0o200; +const UTIME_OMIT = 0x3ffffffe; + +describe("HostFileSystem component-wise path resolution", () => { + let top: string; + let root: string; + let outside: string; + let hostFs: HostFileSystem; + + beforeEach(() => { + top = mkdtempSync(join(tmpdir(), "kandelo-host-fs-path-")); + root = join(top, "root"); + outside = join(top, "outside"); + mkdirSync(root); + mkdirSync(outside); + hostFs = new HostFileSystem(root, "/mnt"); + }); + + afterEach(() => { + rmSync(top, { recursive: true, force: true }); + }); + + it("allows ordinary names beginning with two dots and resolves components in order", () => { + writeFileSync(join(root, "..visible"), "visible"); + mkdirSync(join(root, "existing")); + writeFileSync(join(root, "existing", "file"), "data"); + + expect(hostFs.stat("/..visible").size).toBe(7); + expect(() => hostFs.stat("/existing/missing/../file")).toThrow(/ENOENT/); + }); + + it("follows normal relative and in-mount absolute symlinks", () => { + writeFileSync(join(root, "target.txt"), "target"); + symlinkSync("target.txt", join(root, "relative-link")); + symlinkSync("/mnt/target.txt", join(root, "absolute-link")); + + expect(hostFs.stat("/relative-link").size).toBe(6); + expect(hostFs.stat("/absolute-link").size).toBe(6); + expect(hostFs.readlink("/relative-link")).toBe("target.txt"); + + hostFs.unlink("/relative-link"); + expect(existsSync(join(root, "relative-link"))).toBe(false); + expect(existsSync(join(root, "target.txt"))).toBe(true); + }); + + it("does not follow a final symlink for an exclusive create", () => { + symlinkSync("created-through-link", join(root, "exclusive-link")); + + expect(() => + hostFs.open("/exclusive-link", O_WRONLY | O_CREAT | O_EXCL, 0o600), + ).toThrow(/EEXIST/); + expect(existsSync(join(root, "created-through-link"))).toBe(false); + }); + + it("does not follow a dangling final symlink for mkdir", () => { + symlinkSync("created-directory", join(root, "directory-link")); + + expect(() => hostFs.mkdir("/directory-link", 0o755)).toThrow(/EEXIST/); + expect(existsSync(join(root, "created-directory"))).toBe(false); + }); + + it("does not follow a dangling hard-link destination", () => { + writeFileSync(join(root, "source"), "source"); + symlinkSync("created-hard-link", join(root, "destination-link")); + + expect(() => hostFs.link("/source", "/destination-link")).toThrow(/EEXIST/); + expect(existsSync(join(root, "created-hard-link"))).toBe(false); + }); + + it("keeps native link semantics for a symlink source inside the mount", () => { + writeFileSync(join(root, "target"), "target"); + symlinkSync("target", join(root, "source-link")); + + hostFs.link("/source-link", "/linked-symlink"); + + const sourceLink = hostFs.lstat("/source-link"); + const target = hostFs.stat("/target"); + const linked = hostFs.lstat("/linked-symlink"); + expect([sourceLink.ino, target.ino]).toContain(linked.ino); + if ((linked.mode & 0xf000) === 0xa000) { + expect(hostFs.readlink("/linked-symlink")).toBe("target"); + } else { + expect(linked.ino).toBe(target.ino); + } + }); + + it("revalidates an intermediate directory after an external replacement", () => { + mkdirSync(join(root, "cached")); + writeFileSync(join(root, "cached", "inside"), "inside"); + writeFileSync(join(outside, "secret"), "outside-secret"); + + expect(hostFs.stat("/cached/inside").size).toBe(6); + renameSync(join(root, "cached"), join(root, "old-cached")); + symlinkSync(outside, join(root, "cached")); + + expect(() => hostFs.stat("/cached/secret")).toThrow(/EACCES/); + }); +}); + +describe("HostFileSystem utimens metadata", () => { + let root: string; + let hostFs: HostFileSystem; + + beforeEach(() => { + root = mkdtempSync(join(tmpdir(), "kandelo-host-fs-utimens-")); + hostFs = new HostFileSystem(root); + }); + + afterEach(() => { + rmSync(root, { recursive: true, force: true }); + }); + + it("preserves an omitted timestamp", () => { + const nativePath = join(root, "timestamps"); + writeFileSync(nativePath, "data"); + utimesSync(nativePath, 10, 20); + const before = statSync(nativePath); + + hostFs.utimensat("/timestamps", 0, UTIME_OMIT, 30, 0); + + const result = hostFs.stat("/timestamps"); + expect(result.atimeMs).toBe(before.atimeMs); + expect(result.mtimeMs).toBe(30_000); + }); + + it("drops timestamp overrides after an external native mutation", () => { + const nativePath = join(root, "externally-mutated"); + writeFileSync(nativePath, "before"); + hostFs.utimensat("/externally-mutated", 1, 0, 2, 0); + expect(hostFs.stat("/externally-mutated").mtimeMs).toBe(2_000); + + writeFileSync(nativePath, "after external mutation"); + const nativeAfter = statSync(nativePath); + + expect(hostFs.stat("/externally-mutated").mtimeMs).toBe( + nativeAfter.mtimeMs, + ); + }); +}); diff --git a/host/test/vfs/host-fs-uid-gid.test.ts b/host/test/vfs/host-fs-uid-gid.test.ts index 50dddd9a7d..d0259fb9d1 100644 --- a/host/test/vfs/host-fs-uid-gid.test.ts +++ b/host/test/vfs/host-fs-uid-gid.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeAll, afterAll } from "vitest"; -import { chmodSync, fstatSync, mkdtempSync, rmSync, statSync, writeFileSync, mkdirSync } from "node:fs"; +import { chmodSync, fstatSync, lstatSync, mkdtempSync, rmSync, statSync, symlinkSync, writeFileSync, mkdirSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { HostFileSystem } from "../../src/vfs/host-fs"; @@ -86,6 +86,33 @@ describe("HostFileSystem uid/gid normalization", () => { expect(nativeAfter.gid).toBe(nativeBefore.gid); }); + it("lchown updates only symlink metadata, including for dangling links", () => { + const targetPath = join(root, "lchown-target.txt"); + const linkPath = join(root, "lchown-link"); + const danglingPath = join(root, "lchown-dangling"); + writeFileSync(targetPath, "target"); + symlinkSync("lchown-target.txt", linkPath); + symlinkSync("missing-target", danglingPath); + const nativeLinkBefore = lstatSync(linkPath); + const nativeTargetBefore = statSync(targetPath); + + const hfs = new HostFileSystem(root); + hfs.lchown("/lchown-link", 1234, 5678); + hfs.lchown("/lchown-dangling", 2345, 6789); + + expect(hfs.lstat("/lchown-link")).toMatchObject({ uid: 1234, gid: 5678 }); + expect(hfs.stat("/lchown-link")).toMatchObject({ uid: 0, gid: 0 }); + expect(hfs.lstat("/lchown-dangling")).toMatchObject({ uid: 2345, gid: 6789 }); + expect(lstatSync(linkPath)).toMatchObject({ + uid: nativeLinkBefore.uid, + gid: nativeLinkBefore.gid, + }); + expect(statSync(targetPath)).toMatchObject({ + uid: nativeTargetBefore.uid, + gid: nativeTargetBefore.gid, + }); + }); + it("fchmod and fchown update virtual metadata without native changes", () => { const nativePath = join(root, "fd.txt"); writeFileSync(nativePath, "hi"); diff --git a/host/test/vfs/image-helpers.test.ts b/host/test/vfs/image-helpers.test.ts new file mode 100644 index 0000000000..547595e249 --- /dev/null +++ b/host/test/vfs/image-helpers.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, it, vi } from "vitest"; +import { MemoryFileSystem } from "../../src/vfs/memory-fs"; +import { writeVfsBinary, writeVfsFile } from "../../src/vfs/image-helpers"; +import { + writeVfsBinary as writeBrowserVfsBinary, + writeVfsFile as writeBrowserVfsFile, +} from "../../../apps/browser-demos/lib/init/vfs-utils"; + +const O_RDONLY = 0; + +function readFile(fs: MemoryFileSystem, path: string): Uint8Array { + const size = fs.stat(path).size; + const bytes = new Uint8Array(size); + const fd = fs.open(path, O_RDONLY, 0); + try { + const read = fs.read(fd, bytes, null, bytes.length); + if (read !== bytes.length) { + throw new Error(`short test read: ${read} of ${bytes.length}`); + } + } finally { + fs.close(fd); + } + return bytes; +} + +describe("VFS image write helpers", () => { + it("backs browser-demo writers with the shared strict helpers", () => { + expect(writeBrowserVfsBinary).toBe(writeVfsBinary); + expect(writeBrowserVfsFile).toBe(writeVfsFile); + }); + + it("stages every byte of a binary file", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(4 * 1024 * 1024)); + const data = new Uint8Array(256 * 1024); + for (let i = 0; i < data.length; i++) data[i] = i & 0xff; + + writeVfsBinary(fs, "/payload.bin", data, 0o640); + + expect(fs.stat("/payload.bin").size).toBe(data.length); + expect(readFile(fs, "/payload.bin")).toEqual(data); + }); + + it("reports terminal ENOSPC after preserving a positive partial write", () => { + const fs = MemoryFileSystem.create(new SharedArrayBuffer(128 * 1024)); + const data = new Uint8Array(1024 * 1024).fill(0xa5); + + expect(() => writeVfsBinary(fs, "/partial.bin", data)).toThrow(); + expect(fs.stat("/partial.bin").size).toBeGreaterThan(0); + expect(fs.stat("/partial.bin").size).toBeLessThan(data.length); + }); + + it("continues from the correct offset after a positive short write", () => { + const data = new Uint8Array([1, 2, 3, 4]); + const close = vi.fn(); + const write = vi.fn() + .mockReturnValueOnce(2) + .mockReturnValueOnce(2); + const fs = { + open: vi.fn(() => 7), + write, + close, + } as unknown as MemoryFileSystem; + + writeVfsBinary(fs, "/fixture.bin", data); + + expect(write).toHaveBeenCalledTimes(2); + expect(write.mock.calls[0][1]).toEqual(new Uint8Array([1, 2, 3, 4])); + expect(write.mock.calls[0].slice(2)).toEqual([0, 4]); + expect(write.mock.calls[1][1]).toEqual(new Uint8Array([3, 4])); + expect(write.mock.calls[1].slice(2)).toEqual([2, 2]); + expect(close).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(7); + }); + + it("closes the descriptor for zero, negative, invalid, and thrown writes", () => { + const data = new Uint8Array([1, 2, 3, 4]); + const outcomes: Array = [ + 0, + -28, + 5, + new Error("ENOSPC"), + ]; + + for (const outcome of outcomes) { + const close = vi.fn(); + const fs = { + open: vi.fn(() => 7), + write: vi.fn(() => { + if (outcome instanceof Error) throw outcome; + return outcome; + }), + close, + } as unknown as MemoryFileSystem; + + expect(() => writeVfsBinary(fs, "/fixture.bin", data)).toThrow(); + expect(close).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledWith(7); + } + }); +}); diff --git a/host/test/vfs/sharedfs-uid-gid.test.ts b/host/test/vfs/sharedfs-uid-gid.test.ts index f22c9a7747..6a2404398f 100644 --- a/host/test/vfs/sharedfs-uid-gid.test.ts +++ b/host/test/vfs/sharedfs-uid-gid.test.ts @@ -92,6 +92,23 @@ describe("SharedFS uid/gid", () => { expect(st.gid).toBe(1000); }); + it("lchown changes a final symlink without changing its target", () => { + const sab = new SharedArrayBuffer(1024 * 1024); + const fs = MemoryFileSystem.create(sab); + const fd = fs.open("/target", O_WRONLY | O_CREAT | O_TRUNC, 0o644); + fs.close(fd); + fs.chown("/target", 100, 200); + fs.symlink("/target", "/link"); + fs.symlink("/missing", "/dangling"); + + fs.lchown("/link", 300, 400); + fs.lchown("/dangling", 500, 600); + + expect(fs.lstat("/link")).toMatchObject({ uid: 300, gid: 400 }); + expect(fs.stat("/link")).toMatchObject({ uid: 100, gid: 200 }); + expect(fs.lstat("/dangling")).toMatchObject({ uid: 500, gid: 600 }); + }); + it("fchown changes uid/gid via fd", () => { const sab = new SharedArrayBuffer(1024 * 1024); const fs = MemoryFileSystem.create(sab); diff --git a/host/test/virtual-network-e2e.test.ts b/host/test/virtual-network-e2e.test.ts index 0f70d0c087..a7c5b9f805 100644 --- a/host/test/virtual-network-e2e.test.ts +++ b/host/test/virtual-network-e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import * as net from "node:net"; import { resolveBinary, tryResolveBinary } from "../src/binary-resolver"; import { LocalVirtualNetwork } from "../src/networking/virtual-network"; import { NodePlatformIO } from "../src/platform/node"; @@ -28,6 +29,129 @@ function waitMs(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } +async function unusedTcpPort(): Promise { + const server = net.createServer(); + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "127.0.0.1", resolve); + }); + const port = (server.address() as net.AddressInfo).port; + await new Promise((resolve) => server.close(() => resolve())); + return port; +} + +async function connectLoopbackWithRetry( + port: number, + allowHalfOpen = false, +): Promise { + const deadline = Date.now() + 5_000; + for (;;) { + try { + return await new Promise((resolve, reject) => { + const socket = new net.Socket({ allowHalfOpen }); + const onError = (error: Error) => { + socket.destroy(); + reject(error); + }; + socket.once("connect", () => { + socket.off("error", onError); + resolve(socket); + }); + socket.once("error", onError); + socket.connect({ host: "127.0.0.1", port }); + }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ECONNREFUSED" || Date.now() >= deadline) { + throw error; + } + await waitMs(10); + } + } +} + +async function waitForReusableTcpPort(port: number): Promise { + const deadline = Date.now() + 5_000; + for (;;) { + const server = net.createServer(); + try { + await new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(port, "0.0.0.0", resolve); + }); + await new Promise((resolve) => server.close(() => resolve())); + return; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EADDRINUSE" || Date.now() >= deadline) { + throw error; + } + await waitMs(10); + } + } +} + +async function readReplyAfterRequest(socket: net.Socket, request: string): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error("TCP reply timed out")); + }, 5_000); + let sawEnd = false; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.once("end", () => { + sawEnd = true; + clearTimeout(timeout); + resolve(Buffer.concat(chunks)); + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + socket.once("close", (hadError) => { + if (!sawEnd && !hadError) { + clearTimeout(timeout); + reject(new Error("TCP socket closed before orderly EOF")); + } + }); + socket.end(request); + }); +} + +async function sendAfterPeerFin( + socket: net.Socket, + request: string, + postFinBytes: number, +): Promise { + const chunks: Buffer[] = []; + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + socket.destroy(); + reject(new Error("post-FIN exchange timed out")); + }, 5_000); + let sawEnd = false; + socket.on("data", (chunk) => chunks.push(chunk)); + socket.once("end", () => { + sawEnd = true; + socket.end(Buffer.alloc(postFinBytes, 0x78)); + }); + socket.once("error", (error) => { + clearTimeout(timeout); + reject(error); + }); + socket.once("close", (hadError) => { + clearTimeout(timeout); + if (hadError) { + reject(new Error("post-FIN socket closed with an error")); + } else if (!sawEnd) { + reject(new Error("post-FIN socket closed before orderly EOF")); + } else { + resolve(Buffer.concat(chunks)); + } + }); + socket.write(request); + }); +} + describe.skipIf(!udpServerPath || !udpClientPath)("virtual network guest socket integration", () => { it("routes POSIX UDP sendto/recvfrom between two Kandelo machines", async () => { const network = new LocalVirtualNetwork(); @@ -90,17 +214,29 @@ describe.skipIf(!tcpServerPath || !tcpClientPath)("virtual network TCP guest soc const serverIO = machineIO(network, "server", [10, 88, 0, 2]); const clientIO = machineIO(network, "client", [10, 88, 0, 3]); const port = 24124; + const postFinBytes = 128 * 1024 + 123; const serverRun = runCentralizedProgram({ programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), - argv: ["virtual-tcp-echo-server", String(port)], + argv: [ + "virtual-tcp-echo-server", + String(port), + "half-close-bulk", + String(postFinBytes), + ], io: serverIO, timeout: 10_000, }); await waitMs(100); const clientRun = runCentralizedProgram({ programPath: resolveBinary("programs/virtual-tcp-echo-client.wasm"), - argv: ["virtual-tcp-echo-client", "10.88.0.2", String(port), "ping"], + argv: [ + "virtual-tcp-echo-client", + "10.88.0.2", + String(port), + "ping", + String(postFinBytes), + ], io: clientIO, timeout: 10_000, }); @@ -120,6 +256,114 @@ describe.skipIf(!tcpServerPath || !tcpClientPath)("virtual network TCP guest soc }, 15_000); }); +describe.skipIf(!tcpServerPath)("Node TCP guest socket integration", () => { + it("keeps a fork-inherited accepted socket alive after the parent exits", async () => { + const port = await unusedTcpPort(); + const bulkBytes = 2 * 1024 * 1024; + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: ["virtual-tcp-echo-server", String(port), "fork-bulk", String(bulkBytes)], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port); + const server = await serverRun; + const response = await readReplyAfterRequest(socket, "ping"); + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.subarray(0, 9).toString("utf8")).toBe("echo:ping"); + expect(response).toHaveLength(9 + bulkBytes); + expect(response.subarray(9).every((byte) => byte === 0x78)).toBe(true); + + // The listener must remain tracked by the child after parent cleanup and + // then close when the child becomes the final owner. + await waitForReusableTcpPort(port); + }, 15_000); + + it("wakes a blocked accepted read on a zero-byte peer FIN", async () => { + const port = await unusedTcpPort(); + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: ["virtual-tcp-echo-server", String(port)], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port); + await waitMs(100); + const [server, response] = await Promise.all([ + serverRun, + readReplyAfterRequest(socket, ""), + ]); + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.toString("utf8")).toBe("echo:"); + }, 15_000); + + it("drains native data queued before FIN after guest SHUT_WR", async () => { + const port = await unusedTcpPort(); + const postFinBytes = 128 * 1024 + 123; + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: [ + "virtual-tcp-echo-server", + String(port), + "half-close-bulk", + String(postFinBytes), + ], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port, true); + const [server, response] = await Promise.all([ + serverRun, + sendAfterPeerFin(socket, "ping", postFinBytes), + ]); + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.toString("utf8")).toBe("echo:ping"); + }, 15_000); + + it("flushes a queued accepted reply after the guest closes and exits", async () => { + const port = await unusedTcpPort(); + const bulkBytes = 2 * 1024 * 1024; + const serverRun = runCentralizedProgram({ + programPath: resolveBinary("programs/virtual-tcp-echo-server.wasm"), + argv: ["virtual-tcp-echo-server", String(port), "bulk", String(bulkBytes)], + io: new NodePlatformIO(), + timeout: 10_000, + }); + + const socket = await connectLoopbackWithRetry(port); + const responsePromise = readReplyAfterRequest(socket, "ping"); + socket.pause(); + const server = await serverRun; + + // The previous bridge destroyed accepted sockets one second after the + // owning pid exited. Keep the native reader paused beyond that boundary: + // only a connection whose lifetime follows pipe/socket ownership can + // retain and later flush the whole queued response. + await waitMs(1_200); + socket.resume(); + const response = await responsePromise; + + expect(server.exitCode).toBe(0); + expect(server.stdout).toBe(""); + expect(server.stderr).toBe(""); + expect(response.subarray(0, 9).toString("utf8")).toBe("echo:ping"); + expect(response).toHaveLength(9 + bulkBytes); + expect(response.subarray(9).every((byte) => byte === 0x78)).toBe(true); + }, 15_000); +}); + describe.skipIf(!ncPath)("virtual network nc integration", () => { it("uses the packaged nc over the local virtual TCP network", async () => { const network = new LocalVirtualNetwork(); diff --git a/host/test/virtual-network.test.ts b/host/test/virtual-network.test.ts index 2795420307..f03814c352 100644 --- a/host/test/virtual-network.test.ts +++ b/host/test/virtual-network.test.ts @@ -8,9 +8,31 @@ import type { TcpConnectionPeer, UdpDatagram } from "../src/types"; const POLLIN = 0x0001; const POLLOUT = 0x0004; +const POLLERR = 0x0008; const POLLHUP = 0x0010; +const MSG_PEEK = 0x0002; describe("LocalVirtualNetwork", () => { + it("resolves bounded legacy numeric IPv4 forms and valid DNS aliases", () => { + const net = new LocalVirtualNetwork(); + const backend = net.attachMachine({ + id: "server", + address: [10, 88, 0, 2], + hostnames: ["example.test", "example.test."], + }); + + expect(Array.from(backend.getaddrinfo("2130706433"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.1"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.1.1"))).toEqual([127, 1, 0, 1]); + expect(Array.from(backend.getaddrinfo("127.0.0.1"))).toEqual([127, 0, 0, 1]); + expect(Array.from(backend.getaddrinfo("example.test"))).toEqual([10, 88, 0, 2]); + expect(Array.from(backend.getaddrinfo("example.test."))).toEqual([10, 88, 0, 2]); + + expect(() => backend.getaddrinfo("4294967296")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("1..2")).toThrow("ENOENT"); + expect(() => backend.getaddrinfo("1.2.3.256")).toThrow("ENOENT"); + }); + it("routes TCP streams between attached machines", () => { const net = new LocalVirtualNetwork(); const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); @@ -35,6 +57,27 @@ describe("LocalVirtualNetwork", () => { expect(new TextDecoder().decode(client.recv(7, 16, 0))).toBe("pong"); }); + it("honors MSG_PEEK without consuming TCP stream data", () => { + const net = new LocalVirtualNetwork(); + const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); + const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); + let accepted: TcpConnectionPeer | null = null; + + expect(server.listenTcp!("srv:1", new Uint8Array([10, 88, 0, 2]), 8080, { + accept(peer) { + accepted = peer; + return 0; + }, + })).toBe(0); + + client.connect(7, new Uint8Array([10, 88, 0, 2]), 8080); + expect(accepted).not.toBeNull(); + accepted!.send(new TextEncoder().encode("peek-data"), 0); + + expect(new TextDecoder().decode(client.recv(7, 4, MSG_PEEK))).toBe("peek"); + expect(new TextDecoder().decode(client.recv(7, 9, 0))).toBe("peek-data"); + }); + it("reports refused TCP connects when no listener is bound", () => { const net = new LocalVirtualNetwork(); const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); @@ -52,7 +95,7 @@ describe("LocalVirtualNetwork", () => { expect(client.connectStatus(1)).toBe(VIRTUAL_NETWORK_ERRNO.EHOSTUNREACH); }); - it("wakes TCP peers with EOF and EPIPE when a machine detaches", () => { + it("wakes TCP peers with reset when a machine detaches", () => { const net = new LocalVirtualNetwork(); const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); @@ -69,18 +112,101 @@ describe("LocalVirtualNetwork", () => { net.detachMachine("server"); const revents = client.poll!(7, POLLIN | POLLOUT); + expect(revents & POLLERR).toBe(POLLERR); expect(revents & POLLIN).toBe(POLLIN); expect(revents & POLLOUT).toBe(0); expect(revents & POLLHUP).toBe(POLLHUP); - expect(client.recv(7, 16, 0)).toHaveLength(0); + try { + client.recv(7, 16, 0); + throw new Error("recv after detached peer unexpectedly succeeded"); + } catch (error) { + expect((error as Error & { errno?: number }).errno).toBe(VIRTUAL_NETWORK_ERRNO.ECONNRESET); + } try { client.send(7, new TextEncoder().encode("after-detach"), 0); throw new Error("send after detached peer unexpectedly succeeded"); } catch (error) { - expect((error as Error & { errno?: number }).errno).toBe(32); + expect((error as Error & { errno?: number }).errno).toBe(VIRTUAL_NETWORK_ERRNO.ECONNRESET); } }); + it("drains queued TCP data before FIN and keeps an orphaned receive sink", () => { + const net = new LocalVirtualNetwork(); + const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); + const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); + let accepted: TcpConnectionPeer | null = null; + + expect(server.listenTcp!("srv:1", new Uint8Array([10, 88, 0, 2]), 8080, { + accept(peer) { + accepted = peer; + return 0; + }, + })).toBe(0); + + client.connect(7, new Uint8Array([10, 88, 0, 2]), 8080); + expect(client.connectStatus(7)).toBe(0); + expect(accepted).not.toBeNull(); + + expect(accepted!.send(new TextEncoder().encode("queued"), 0)).toBe(6); + accepted!.close(); + + expect(new TextDecoder().decode(client.recv(7, 16, 0))).toBe("queued"); + expect(client.recv(7, 16, 0)).toHaveLength(0); + const revents = client.poll!(7, POLLIN | POLLOUT); + expect(revents & POLLIN).toBe(POLLIN); + expect(revents & POLLOUT).toBe(POLLOUT); + expect(client.send(7, new TextEncoder().encode("after-fin-one"), 0)).toBe(13); + expect(client.send(7, new TextEncoder().encode("after-fin-two"), 0)).toBe(13); + client.close(7); + }); + + it("preserves queued TCP data when a cleanly closed machine detaches", () => { + const net = new LocalVirtualNetwork(); + const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); + const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); + let accepted: TcpConnectionPeer | null = null; + + expect(server.listenTcp!("srv:1", new Uint8Array([10, 88, 0, 2]), 8080, { + accept(peer) { + accepted = peer; + return 0; + }, + })).toBe(0); + + client.connect(7, new Uint8Array([10, 88, 0, 2]), 8080); + expect(client.connectStatus(7)).toBe(0); + expect(accepted).not.toBeNull(); + + expect(client.send(7, new TextEncoder().encode("queued before close"), 0)).toBe(19); + client.close(7); + net.detachMachine("client"); + + expect(new TextDecoder().decode(accepted!.recv(32, 0))).toBe("queued before close"); + expect(accepted!.recv(32, 0)).toHaveLength(0); + }); + + it("keeps the receive direction usable after SHUT_WR", () => { + const net = new LocalVirtualNetwork(); + const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); + const client = net.attachMachine({ id: "client", address: [10, 88, 0, 3] }); + let accepted: TcpConnectionPeer | null = null; + + expect(server.listenTcp!("srv:1", new Uint8Array([10, 88, 0, 2]), 8080, { + accept(peer) { + accepted = peer; + return 0; + }, + })).toBe(0); + + client.connect(7, new Uint8Array([10, 88, 0, 2]), 8080); + expect(client.connectStatus(7)).toBe(0); + accepted!.shutdown(1); + + expect(client.recv(7, 16, 0)).toHaveLength(0); + expect(client.send(7, new TextEncoder().encode("still-readable"), 0)).toBe(14); + expect(new TextDecoder().decode(accepted!.recv(16, 0))).toBe("still-readable"); + }); + it("routes UDP datagrams and preserves source metadata", () => { const net = new LocalVirtualNetwork(); const server = net.attachMachine({ id: "server", address: [10, 88, 0, 2] }); diff --git a/host/test/vm-interrupt-timer.test.ts b/host/test/vm-interrupt-timer.test.ts new file mode 100644 index 0000000000..f5b78af108 --- /dev/null +++ b/host/test/vm-interrupt-timer.test.ts @@ -0,0 +1,251 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + MAX_VM_INTERRUPT_TIMER_DELAY_MS, + VmInterruptTimerManager, + type VmInterruptTimerScheduler, +} from "../src/vm-interrupt-timer"; + +interface Generation { + memory: WebAssembly.Memory; +} + +function generation(shared = true): Generation { + return { + memory: new WebAssembly.Memory({ + initial: 1, + maximum: 2, + shared, + }), + }; +} + +function flag(generation: Generation, ptr: number): number { + return Atomics.load(new Uint8Array(generation.memory.buffer), ptr); +} + +describe("VmInterruptTimerManager", () => { + let nowMs: number; + let current: Map; + let delays: number[]; + let scheduler: VmInterruptTimerScheduler; + let manager: VmInterruptTimerManager; + + beforeEach(() => { + vi.useFakeTimers(); + nowMs = 0; + current = new Map(); + delays = []; + scheduler = { + now: () => nowMs, + set: (callback, delayMs) => { + delays.push(delayMs); + return setTimeout(callback, delayMs); + }, + clear: (handle) => clearTimeout(handle), + }; + manager = new VmInterruptTimerManager((pid) => current.get(pid), scheduler); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + function advance(ms: number): void { + nowMs += ms; + vi.advanceTimersByTime(ms); + } + + it("sets both flags at the exact monotonic deadline", () => { + const process = generation(); + current.set(41, process); + + expect(manager.arm(41, process, { + timedOutPtr: 12, + vmInterruptPtr: 13, + seconds: 1, + })).toBe(true); + + advance(999); + expect(flag(process, 12)).toBe(0); + expect(flag(process, 13)).toBe(0); + + advance(1); + expect(flag(process, 12)).toBe(1); + expect(flag(process, 13)).toBe(1); + expect(manager.activeCount).toBe(0); + }); + + it("re-arms from the new request time and suppresses the old callback", () => { + const process = generation(); + current.set(42, process); + + manager.arm(42, process, { + timedOutPtr: 20, + vmInterruptPtr: 21, + seconds: 1, + }); + advance(500); + manager.arm(42, process, { + timedOutPtr: 20, + vmInterruptPtr: 21, + seconds: 2, + }); + + advance(500); + expect(flag(process, 20)).toBe(0); + advance(1_499); + expect(flag(process, 20)).toBe(0); + advance(1); + expect(flag(process, 20)).toBe(1); + }); + + it("cancels through a non-positive runtime-hook request", () => { + const process = generation(); + current.set(43, process); + manager.arm(43, process, { + timedOutPtr: 24, + vmInterruptPtr: 25, + seconds: 1, + }); + + expect(manager.handleRequest(43, process, { + timedOutPtr: 24, + vmInterruptPtr: 25, + seconds: 0, + })).toBe(true); + advance(1_000); + + expect(flag(process, 24)).toBe(0); + expect(flag(process, 25)).toBe(0); + expect(manager.activeCount).toBe(0); + }); + + it("refuses a stale process generation without disturbing its replacement", () => { + const oldProcess = generation(); + const newProcess = generation(); + current.set(44, oldProcess); + manager.arm(44, oldProcess, { + timedOutPtr: 28, + vmInterruptPtr: 29, + seconds: 1, + }); + + current.set(44, newProcess); + manager.arm(44, newProcess, { + timedOutPtr: 30, + vmInterruptPtr: 31, + seconds: 1, + }); + expect(manager.cancel(44, oldProcess)).toBe(false); + + advance(1_000); + expect(flag(oldProcess, 28)).toBe(0); + expect(flag(newProcess, 30)).toBe(1); + expect(flag(newProcess, 31)).toBe(1); + }); + + it("drops a queued callback when the PID generation changes", () => { + const oldProcess = generation(); + const replacement = generation(); + current.set(45, oldProcess); + manager.arm(45, oldProcess, { + timedOutPtr: 32, + vmInterruptPtr: 33, + seconds: 1, + }); + + current.set(45, replacement); + advance(1_000); + + expect(flag(oldProcess, 32)).toBe(0); + expect(flag(replacement, 32)).toBe(0); + expect(manager.activeCount).toBe(0); + }); + + it("chunks delays above the JavaScript signed-32-bit timer limit", () => { + const process = generation(); + current.set(46, process); + const tailMs = 2_500; + manager.arm(46, process, { + timedOutPtr: 36, + vmInterruptPtr: 37, + seconds: (MAX_VM_INTERRUPT_TIMER_DELAY_MS + tailMs) / 1_000, + }); + + expect(delays).toEqual([MAX_VM_INTERRUPT_TIMER_DELAY_MS]); + advance(MAX_VM_INTERRUPT_TIMER_DELAY_MS); + expect(flag(process, 36)).toBe(0); + expect(delays).toEqual([MAX_VM_INTERRUPT_TIMER_DELAY_MS, tailMs]); + + advance(tailMs - 1); + expect(flag(process, 36)).toBe(0); + advance(1); + expect(flag(process, 36)).toBe(1); + }); + + it("rejects out-of-bounds and non-shared flag storage", () => { + const process = generation(); + current.set(47, process); + expect(manager.arm(47, process, { + timedOutPtr: process.memory.buffer.byteLength, + vmInterruptPtr: 1, + seconds: 1, + })).toBe(false); + + const unshared = generation(false); + current.set(48, unshared); + expect(manager.arm(48, unshared, { + timedOutPtr: 1, + vmInterruptPtr: 2, + seconds: 1, + })).toBe(false); + expect(manager.activeCount).toBe(0); + }); + + it("clears every process timer", () => { + const first = generation(); + const second = generation(); + current.set(49, first); + current.set(50, second); + manager.arm(49, first, { timedOutPtr: 40, vmInterruptPtr: 41, seconds: 1 }); + manager.arm(50, second, { timedOutPtr: 42, vmInterruptPtr: 43, seconds: 1 }); + + manager.clearAll(); + advance(1_000); + + expect(flag(first, 40)).toBe(0); + expect(flag(second, 42)).toBe(0); + expect(manager.activeCount).toBe(0); + }); + + it("cancels a timer whose scheduler handle is numeric zero", () => { + const process = generation(); + const callbacks: Array<() => void> = []; + const cleared: number[] = []; + const zeroHandleScheduler: VmInterruptTimerScheduler = { + now: () => 0, + set: (callback) => { + callbacks.push(callback); + return 0; + }, + clear: (handle) => cleared.push(handle), + }; + current.set(51, process); + const zeroManager = new VmInterruptTimerManager( + (pid) => current.get(pid), + zeroHandleScheduler, + ); + + zeroManager.arm(51, process, { + timedOutPtr: 44, + vmInterruptPtr: 45, + seconds: 1, + }); + expect(zeroManager.cancel(51, process)).toBe(true); + expect(cleared).toEqual([0]); + + callbacks[0](); + expect(flag(process, 44)).toBe(0); + expect(flag(process, 45)).toBe(0); + }); +}); diff --git a/host/test/wait-lifecycle-guest.test.ts b/host/test/wait-lifecycle-guest.test.ts new file mode 100644 index 0000000000..c946b7f3dc --- /dev/null +++ b/host/test/wait-lifecycle-guest.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "./centralized-test-helper"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +describe("POSIX child wait lifecycle", () => { + it("reports stop, continue, exit, WNOWAIT, rusage, procfs, and stopped SIGKILL", async () => { + const program = join(__dirname, "../../examples/wait_lifecycle_test.wasm"); + const result = await runCentralizedProgram({ + programPath: program, + argv: ["wait-lifecycle-test"], + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("WAIT_LIFECYCLE_PASS"); + expect(result.stderr).toBe(""); + }); + + it("uses memory64 wait layouts through same-arch non-forking posix_spawn", async () => { + const program = join( + __dirname, + "../../examples/wait_lifecycle_test.wasm64.wasm", + ); + const result = await runCentralizedProgram({ + programPath: program, + argv: ["wait-lifecycle-test-wasm64"], + execPrograms: new Map([ + ["/wait-lifecycle-test-wasm64", program], + ]), + useDefaultRootfs: false, + timeout: 30_000, + captureForkCount: true, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("WAIT_LIFECYCLE_PASS"); + expect(result.stderr).toBe(""); + expect(result.forkCount).toBe(0n); + }); +}); diff --git a/host/test/wasm-trap.test.ts b/host/test/wasm-trap.test.ts index f48a0a346f..4644509244 100644 --- a/host/test/wasm-trap.test.ts +++ b/host/test/wasm-trap.test.ts @@ -45,7 +45,7 @@ const programsBuilt = existsSync(wasmTrapBin) && describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { it("__builtin_trap() in user code: spawn() resolves promptly, no hang", async () => { const t0 = Date.now(); - const { exitCode, stderr } = await runCentralizedProgram({ + const { exitCode, stderr, hostDiagnostics } = await runCentralizedProgram({ programPath: wasmTrapBin, useDefaultRootfs: false, timeout: 5000, @@ -54,17 +54,19 @@ describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { expect(stderr).toContain("before-trap"); expect(stderr).not.toContain("SHOULD-NEVER-REACH"); + expect(stderr).not.toContain("RuntimeError"); // Arbitrary `unreachable` traps are no longer masked as successful exits; // only the known kernel_exit path is interpreted as normal termination. expect(exitCode).toBe(signalExitStatus(SIGILL)); - expect(stderr).toContain("RuntimeError"); - expect(stderr).toContain("unreachable"); + const diagnosticText = hostDiagnostics.map((entry) => entry.message).join("\n"); + expect(diagnosticText).toContain("RuntimeError"); + expect(diagnosticText).toContain("unreachable"); expect(elapsed).toBeLessThan(3000); }, 8_000); it("out-of-bounds memory trap resolves as SIGSEGV", async () => { const t0 = Date.now(); - const { exitCode, stderr } = await runCentralizedProgram({ + const { exitCode, stderr, hostDiagnostics } = await runCentralizedProgram({ programPath: oobTrapBin, useDefaultRootfs: false, timeout: 5000, @@ -73,14 +75,15 @@ describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { expect(stderr).toContain("before-oob"); expect(stderr).not.toContain("SHOULD-NEVER-REACH"); + expect(stderr).not.toContain("RuntimeError"); expect(exitCode).toBe(signalExitStatus(SIGSEGV)); - expect(stderr).toContain("RuntimeError"); + expect(hostDiagnostics.map((entry) => entry.message).join("\n")).toContain("RuntimeError"); expect(elapsed).toBeLessThan(3000); }, 8_000); it("integer divide-by-zero trap resolves as SIGFPE", async () => { const t0 = Date.now(); - const { exitCode, stderr } = await runCentralizedProgram({ + const { exitCode, stderr, hostDiagnostics } = await runCentralizedProgram({ programPath: divzeroTrapBin, useDefaultRootfs: false, timeout: 5000, @@ -89,8 +92,9 @@ describe.skipIf(!programsBuilt)("wasm trap → host exit (regression)", () => { expect(stderr).toContain("before-divzero"); expect(stderr).not.toContain("SHOULD-NEVER-REACH"); + expect(stderr).not.toContain("RuntimeError"); expect(exitCode).toBe(signalExitStatus(SIGFPE)); - expect(stderr).toContain("RuntimeError"); + expect(hostDiagnostics.map((entry) => entry.message).join("\n")).toContain("RuntimeError"); expect(elapsed).toBeLessThan(3000); }, 8_000); diff --git a/host/test/wordpress-benchmark-state.test.ts b/host/test/wordpress-benchmark-state.test.ts new file mode 100644 index 0000000000..dc685b3b4e --- /dev/null +++ b/host/test/wordpress-benchmark-state.test.ts @@ -0,0 +1,60 @@ +import { + existsSync, + mkdtempSync, + readdirSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { join, relative } from "node:path"; +import { describe, expect, it } from "vitest"; +import { + buildPhpOpcacheArgs, + createWordPressOpcacheRunDirectory, + removeWordPressOpcacheRunDirectory, + resetWordPressMeasurementState, +} from "../../benchmarks/suites/wordpress-state"; + +describe("WordPress benchmark measurement state", () => { + it("clears every runtime database entry, the debug log, and the measurement cache", () => { + const scratch = mkdtempSync(join(tmpdir(), "kandelo-wordpress-benchmark-state-")); + try { + const databaseDirectory = join(scratch, "wordpress/wp-content/database"); + const debugLogPath = join(scratch, "wordpress/wp-content/debug.log"); + const resultsDirectory = join(scratch, "benchmarks/results"); + const opcacheRunDirectory = createWordPressOpcacheRunDirectory(resultsDirectory); + const opcacheCacheDirectory = join(opcacheRunDirectory, "cli"); + const state = { databaseDirectory, debugLogPath, opcacheCacheDirectory }; + + resetWordPressMeasurementState(state); + writeFileSync(join(databaseDirectory, ".htaccess"), "deny from all\n"); + writeFileSync(join(databaseDirectory, "index.php"), " { + const fileCachePath = "/benchmark-results/wordpress-opcache/http"; + const args = buildPhpOpcacheArgs("/artifacts/php/opcache.so", fileCachePath); + + expect(args).toContain(`opcache.file_cache=${fileCachePath}`); + expect(args).toContain("opcache.file_cache_only=1"); + expect(args).toContain("opcache.validate_timestamps=0"); + expect(args).not.toContain("opcache.file_cache=/tmp"); + }); +}); diff --git a/host/test/worker-adapter.test.ts b/host/test/worker-adapter.test.ts index 767a628379..0df11a803e 100644 --- a/host/test/worker-adapter.test.ts +++ b/host/test/worker-adapter.test.ts @@ -1,5 +1,11 @@ +import { fileURLToPath } from "node:url"; import { describe, it, expect } from "vitest"; -import { MockWorkerAdapter } from "../src/worker-adapter"; +import { + MockWorkerAdapter, + NodeWorkerAdapter, + nodeWorkerOptions, + nodeWorkerStackSizeMb, +} from "../src/worker-adapter"; describe("MockWorkerAdapter", () => { it("should create a worker handle and capture workerData", () => { @@ -48,3 +54,75 @@ describe("MockWorkerAdapter", () => { expect(adapter.lastWorker!.sentMessages).toEqual([{ type: "terminate" }]); }); }); + +describe("NodeWorkerAdapter stack policy", () => { + it("uses 32 MiB by default and validates explicit overrides", () => { + expect(nodeWorkerStackSizeMb(undefined)).toBe(32); + expect(nodeWorkerStackSizeMb("48")).toBe(48); + expect(() => nodeWorkerStackSizeMb("0")).toThrow(/invalid/); + expect(() => nodeWorkerStackSizeMb("-1")).toThrow(/invalid/); + expect(() => nodeWorkerStackSizeMb("not-a-number")).toThrow(/invalid/); + }); + + it("preserves other resource limits when setting the stack limit", () => { + expect(nodeWorkerOptions({ pid: 7 }, { + resourceLimits: { maxOldGenerationSizeMb: 64 }, + })).toMatchObject({ + workerData: { pid: 7 }, + resourceLimits: { + maxOldGenerationSizeMb: 64, + stackSizeMb: 32, + }, + }); + }); + + it("runs a deeply recursive Wasm workload inside the configured worker stack", async () => { + const adapter = new NodeWorkerAdapter( + new URL("./fixtures/deep-wasm-recursion-worker.mjs", import.meta.url), + ); + const worker = adapter.createWorker({ + wasmPath: fileURLToPath( + new URL("./fixtures/deep-wasm-recursion.wasm", import.meta.url), + ), + depth: 300_000, + }); + + try { + const result = await new Promise((resolve, reject) => { + worker.on("message", resolve); + worker.on("error", reject); + worker.on("exit", (code) => { + if (code !== 0) reject(new Error(`deep Wasm worker exited ${code}`)); + }); + }); + expect(result).toEqual({ result: 300_000 }); + } finally { + await worker.terminate(); + } + }, 20_000); + + it("supports the default four concurrent process-worker stack reservations", async () => { + const adapter = new NodeWorkerAdapter( + new URL("./fixtures/deep-wasm-recursion-worker.mjs", import.meta.url), + ); + const wasmPath = fileURLToPath( + new URL("./fixtures/deep-wasm-recursion.wasm", import.meta.url), + ); + const workers = Array.from({ length: 4 }, () => + adapter.createWorker({ wasmPath, depth: 100_000 })); + + try { + const results = await Promise.all(workers.map((worker) => + new Promise((resolve, reject) => { + worker.on("message", resolve); + worker.on("error", reject); + worker.on("exit", (code) => { + if (code !== 0) reject(new Error(`deep Wasm worker exited ${code}`)); + }); + }))); + expect(results).toEqual(Array.from({ length: 4 }, () => ({ result: 100_000 }))); + } finally { + await Promise.all(workers.map((worker) => worker.terminate())); + } + }, 20_000); +}); diff --git a/host/vitest.config.ts b/host/vitest.config.ts index 1e18639369..15463ca982 100644 --- a/host/vitest.config.ts +++ b/host/vitest.config.ts @@ -13,6 +13,11 @@ const viteUrlStub = resolve(__dirname, "test/fixtures/vite-url-stub.ts"); * Worker stub `globalThis.Worker` directly. */ export default defineConfig({ + resolve: { + alias: { + "@host": resolve(__dirname, "src"), + }, + }, plugins: [ { name: "vitest-stub-vite-url-imports", @@ -37,30 +42,17 @@ export default defineConfig({ "../examples/dlopen/**/*.test.ts", ], globalSetup: ["test/global-setup.ts"], - // `pool: 'forks'` avoids the worker_threads RPC that vitest 3.2.4 - // uses for inter-thread task-update messaging. Under the GHA - // runner's CPU contention (two long-running test files — - // release-roundtrip @ ~63s and exec-brk-base @ ~14s — running in - // parallel with ~50 other files), the default `pool: 'threads'` - // hits "Timeout calling onTaskUpdate" *after* all 392 tests pass, - // failing the run on a vitest internal RPC error rather than any - // real test failure. - // - // Forks have higher per-file process-spawn overhead (~20-30s - // added wall-clock for our suite) but no shared-thread RPC, so - // the timeout doesn't apply. A future vitest version (3.2.5+) is - // expected to ship the fix; revisit then. + // Keep test files in child processes. The suite itself starts many + // worker_threads and large shared Wasm memories; nesting that work inside + // Vitest's thread pool has historically made task reporting unreliable + // under GitHub runner contention. pool: "forks", - // Even with forks, the post-run aggregation RPC (`onTaskUpdate`) - // can time out on a heavily contended GHA runner. Fork-heavy host - // test files also launch their own process workers; keep local runs - // parallel, but serialize CI files so dash/fork/spawn coverage has - // enough worker time to make forward progress. + // Fork-heavy files launch their own process workers. Keep local runs + // parallel, but serialize CI files so guest timeouts measure the runtime + // behavior under test instead of runner oversubscription. teardownTimeout: 60_000, - poolOptions: { - forks: { - maxForks: process.env.CI ? 1 : 4, - }, - }, + // Vitest 4 removed poolOptions.forks.maxForks; maxWorkers is the current + // top-level equivalent. + maxWorkers: process.env.CI ? 1 : 4, }, }); diff --git a/images/rootfs/PACKAGES.toml b/images/rootfs/PACKAGES.toml index 1355cc5820..84e702a580 100644 --- a/images/rootfs/PACKAGES.toml +++ b/images/rootfs/PACKAGES.toml @@ -425,6 +425,11 @@ binary = "programs/wasm32/posix-utils-lite/pax.wasm" path = "/usr/bin/pax" aliases = ["/bin/pax"] +[[packages.outputs]] +binary = "programs/wasm32/posix-utils-lite/pgrep.wasm" +path = "/usr/bin/pgrep" +aliases = ["/bin/pgrep"] + [[packages.outputs]] binary = "programs/wasm32/posix-utils-lite/ps.wasm" path = "/usr/bin/ps" diff --git a/images/rootfs/etc/services b/images/rootfs/etc/services index 41b895dbac..bd91937a30 100644 --- a/images/rootfs/etc/services +++ b/images/rootfs/etc/services @@ -8,9 +8,12 @@ ftp 21/tcp ssh 22/tcp telnet 23/tcp smtp 25/tcp mail +nicname 43/tcp whois domain 53/tcp domain 53/udp -http 80/tcp www +gopher 70/tcp +finger 79/tcp +http 80/tcp www www-http pop3 110/tcp pop-3 nntp 119/tcp readnews untp ntp 123/udp @@ -19,3 +22,5 @@ snmp 161/udp https 443/tcp imaps 993/tcp pop3s 995/tcp +mysql 3306/tcp +postgresql 5432/tcp diff --git a/packages/registry/openssl/cacert.pem b/images/rootfs/etc/ssl/cert.pem similarity index 100% rename from packages/registry/openssl/cacert.pem rename to images/rootfs/etc/ssl/cert.pem diff --git a/images/rootfs/etc/ssl/openssl.cnf b/images/rootfs/etc/ssl/openssl.cnf new file mode 100644 index 0000000000..f2ff413157 --- /dev/null +++ b/images/rootfs/etc/ssl/openssl.cnf @@ -0,0 +1,27 @@ +# Kandelo's maintained minimal OpenSSL configuration. OpenSSL is built with +# --openssldir=/etc/ssl, so programs that do not provide an application-level +# configuration load this file from the canonical rootfs image. +openssl_conf = openssl_init + +[openssl_init] + +[req] +default_bits = 2048 +default_md = sha256 +distinguished_name = req_distinguished_name +x509_extensions = v3_ca +req_extensions = v3_req + +[req_distinguished_name] + +[v3_req] +basicConstraints = CA:FALSE +keyUsage = nonRepudiation, digitalSignature, keyEncipherment + +[v3_ca] +subjectKeyIdentifier = hash +authorityKeyIdentifier = keyid:always,issuer:always +basicConstraints = CA:true + +[usr_cert] +basicConstraints = CA:FALSE diff --git a/images/vfs/scripts/build-mariadb-test-vfs-image.ts b/images/vfs/scripts/build-mariadb-test-vfs-image.ts index 2997a38a39..243ee6083c 100644 --- a/images/vfs/scripts/build-mariadb-test-vfs-image.ts +++ b/images/vfs/scripts/build-mariadb-test-vfs-image.ts @@ -28,6 +28,7 @@ import { import { resolveBinary, tryResolveBinary, findRepoRoot } from "../../../host/src/binary-resolver"; import { saveImage, walkAndWrite } from "./vfs-image-helpers"; import { addDinitInit, type DinitService } from "./dinit-image-helpers"; +import { prepareMariadbWritableDirectories } from "./mariadb-image-helpers"; import { ensureSourceExtract } from "./source-extract-helper"; const REPO_ROOT = findRepoRoot(); @@ -225,7 +226,7 @@ async function main() { ]) { ensureDir(fs, dir); } - fs.chmod("/tmp", 0o777); + prepareMariadbWritableDirectories(fs); // dash + coreutils for the bootstrap wrapper script (sh, sleep, kill). if (existsSync(DASH_PATH)) { diff --git a/images/vfs/scripts/build-mariadb-vfs-image.ts b/images/vfs/scripts/build-mariadb-vfs-image.ts index 7f716391ff..8628509c68 100644 --- a/images/vfs/scripts/build-mariadb-vfs-image.ts +++ b/images/vfs/scripts/build-mariadb-vfs-image.ts @@ -27,6 +27,7 @@ import { import { resolveBinary, tryResolveBinary, findRepoRoot } from "../../../host/src/binary-resolver"; import { saveImage } from "./vfs-image-helpers"; import { addDinitInit, type DinitService } from "./dinit-image-helpers"; +import { prepareMariadbWritableDirectories } from "./mariadb-image-helpers"; import { ensureSourceExtract } from "./source-extract-helper"; const REPO_ROOT = findRepoRoot(); @@ -166,7 +167,7 @@ async function main() { ]) { ensureDir(fs, dir); } - fs.chmod("/tmp", 0o777); + prepareMariadbWritableDirectories(fs); // dash + coreutils symlinks (page registers coreutils.wasm lazily). if (existsSync(DASH_PATH)) { diff --git a/images/vfs/scripts/build-php-test-vfs-image.sh b/images/vfs/scripts/build-php-test-vfs-image.sh new file mode 100755 index 0000000000..ac38c0811e --- /dev/null +++ b/images/vfs/scripts/build-php-test-vfs-image.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" + +cd "$REPO_ROOT" +npx tsx "$SCRIPT_DIR/build-php-test-vfs-image.ts" "$@" diff --git a/images/vfs/scripts/build-php-test-vfs-image.ts b/images/vfs/scripts/build-php-test-vfs-image.ts new file mode 100644 index 0000000000..68f66178d2 --- /dev/null +++ b/images/vfs/scripts/build-php-test-vfs-image.ts @@ -0,0 +1,441 @@ +/** + * Build a VFS image for running php-src PHPT runtime tests in the browser. + * + * The image contains: + * - /bin/sh plus standard shell utilities for PHP's shell-backed exec APIs + * - /usr/local/bin/php + * - /php-src/ + * + * The Playwright-side runner parses each .phpt file and writes transient + * PHP scripts into the restored image before spawning /usr/local/bin/php. + */ +import { + cpSync, + existsSync, + lstatSync, + mkdtempSync, + readFileSync, + readdirSync, + readlinkSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { createHash, type Hash } from "node:crypto"; +import { tmpdir } from "node:os"; +import { delimiter, dirname, join, relative } from "node:path"; +import { fileURLToPath } from "node:url"; +import { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { + ensureDirRecursive, + writeVfsBinary, +} from "../../../host/src/vfs/image-helpers"; +import { findRepoRoot, tryResolveBinary } from "../../../host/src/binary-resolver"; +import { preparePhpTestFixtures } from "./php-test-fixtures"; +import { ensureSourceExtract } from "./source-extract-helper"; +import { saveImage, walkAndWrite } from "./vfs-image-helpers"; +import { resolvePackageRuntimeFile } from "../../../scripts/package-runtime-file"; + +const REPO_ROOT = findRepoRoot(); +const PHP_FIXTURE_ROOT = join(REPO_ROOT, "tests/php-fixtures"); +const LOCAL_PHP_SRC = join(REPO_ROOT, "packages/registry/php/php-src"); +const ICU_RUNTIME = resolvePackageRuntimeFile(REPO_ROOT, "php", "icu.dat"); +const PHP_WASM = process.env.PHP_WASM + ?? ICU_RUNTIME?.closureHostPaths.get("php/php.wasm") + ?? join(LOCAL_PHP_SRC, "sapi/cli/php"); +const OPCACHE_SO = process.env.PHP_OPCACHE_SO + ?? ICU_RUNTIME?.closureHostPaths.get("php/opcache.so"); +const PHP_EXTENSION_DIRS = [ + dirname(PHP_WASM), + ...((process.env.PHP_EXTENSION_DIR ?? "") + .split(delimiter) + .map((path) => path.trim()) + .filter(Boolean)), +]; +const INTL_SO = ICU_RUNTIME?.closureHostPaths.get("php/intl.so") + ?? [...PHP_EXTENSION_DIRS] + .reverse() + .map((dir) => join(dir, "intl.so")) + .find((path) => existsSync(path)); +const PHP_FPM_WASM = process.env.PHP_FPM_WASM + ?? ICU_RUNTIME?.closureHostPaths.get("php/php-fpm.wasm"); +const ROOTFS_VFS = process.env.ROOTFS_VFS + ?? tryResolveBinary("rootfs.vfs") + ?? tryResolveBinary("programs/rootfs.vfs") + ?? join(REPO_ROOT, "host/wasm/rootfs.vfs"); +const OUT_FILE = process.env.PHP_TEST_VFS_OUT + ?? join(REPO_ROOT, "apps/browser-demos/public/php-test.vfs.zst"); +const FS_MAX_BYTES = Number(process.env.PHP_TEST_VFS_MAX_BYTES ?? 2 * 1024 * 1024 * 1024); +const META_FILE = `${OUT_FILE}.meta.json`; + +function hashInputPath( + hash: Hash, + label: string, + path: string | null | undefined, +): void { + hash.update(`input\0${label}\0`); + if (!path || !existsSync(path)) { + hash.update("missing\0"); + return; + } + const root = path; + const visit = (current: string) => { + const st = lstatSync(current); + const rel = relative(root, current) || "."; + hash.update(`${rel}\0${st.mode & 0o7777}\0`); + if (st.isSymbolicLink()) { + hash.update(`link\0${readlinkSync(current)}\0`); + } else if (st.isDirectory()) { + hash.update("dir\0"); + for (const entry of readdirSync(current).sort()) { + if (entry === ".git" || entry === ".deps" || entry === ".libs") continue; + visit(join(current, entry)); + } + } else if (st.isFile()) { + hash.update("file\0"); + hash.update(readFileSync(current)); + } else { + hash.update("unsupported\0"); + } + }; + visit(path); +} + +function phpTestVfsFingerprint(sourceRoot: string): string { + const hash = createHash("sha256"); + hash.update(`php-test-vfs-v2\0max=${FS_MAX_BYTES}\0`); + hashInputPath(hash, "builder", fileURLToPath(import.meta.url)); + hashInputPath( + hash, + "fixture-preparation", + join(dirname(fileURLToPath(import.meta.url)), "php-test-fixtures.ts"), + ); + hashInputPath( + hash, + "helpers", + join(dirname(fileURLToPath(import.meta.url)), "vfs-image-helpers.ts"), + ); + hashInputPath(hash, "source", sourceRoot); + hashInputPath(hash, "fixtures", PHP_FIXTURE_ROOT); + hashInputPath(hash, "rootfs", ROOTFS_VFS); + hashInputPath(hash, "php", PHP_WASM); + hashInputPath(hash, "php-fpm", PHP_FPM_WASM); + for (const [index, extensionDir] of PHP_EXTENSION_DIRS.entries()) { + hashInputPath(hash, `extensions-${index}`, extensionDir); + } + hashInputPath(hash, "opcache", OPCACHE_SO); + hashInputPath(hash, "intl", INTL_SO); + hashInputPath(hash, "intl-icu-data", ICU_RUNTIME?.hostPath); + if (ICU_RUNTIME) { + hash.update( + `runtime-contract\0${ICU_RUNTIME.artifact}\0${ICU_RUNTIME.guestPath}\0${ICU_RUNTIME.mode}\0`, + ); + } + return hash.digest("hex"); +} + +function resolvePhpSource(): string { + return process.env.PHP_SOURCE_DIR + ?? ensureSourceExtract("php", REPO_ROOT, existsSync(LOCAL_PHP_SRC) ? LOCAL_PHP_SRC : undefined); +} + +function collectPhptDirs(root: string): string[] { + const dirs = new Set(); + function walk(dir: string) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (entry.name === ".git" || entry.name === ".deps" || entry.name === ".libs") continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walk(full); + } else if (entry.isFile() && entry.name.endsWith(".phpt")) { + dirs.add(dir); + } + } + } + walk(root); + // Some PHPTs include helper fixtures from extension directories that do not + // themselves contain .phpt files. Keep those directories in the browser VFS + // so SKIPIF sections behave like they do against a complete php-src tree. + for (const rel of ["ext/dl_test/tests"]) { + const full = join(root, rel); + if (existsSync(full)) dirs.add(full); + } + return [...dirs].sort(); +} + +const SUPPORT_FILE_PATTERN = + /\.(?:inc|php|phtml|pem|crt|csr|key|cnf|ini|txt|dat|data|json|xml|xsd|dtd|rng|csv|sql|stub)$/i; + +function isTestPath(relPath: string): boolean { + return relPath.split(/[\\/]+/).includes("tests"); +} + +function isSupportFileName(name: string): boolean { + return SUPPORT_FILE_PATTERN.test(name); +} + +function directoryHasSupportFiles(sourceRoot: string, dir: string): boolean { + const relDir = relative(sourceRoot, dir); + if (!relDir || !isTestPath(relDir)) return false; + for (const entry of readdirSync(dir)) { + if (!isSupportFileName(entry)) continue; + try { + if (statSync(join(dir, entry)).isFile()) return true; + } catch { + // Ignore unreadable or disappearing entries. + } + } + return false; +} + +function collectPhptSupportDirs(sourceRoot: string, phptDirs: string[]): string[] { + const dirs = new Set(); + const phptDirSet = new Set(phptDirs); + for (const phptDir of phptDirs) { + let current = dirname(phptDir); + while (current !== sourceRoot && current.startsWith(sourceRoot)) { + if (!phptDirSet.has(current) && directoryHasSupportFiles(sourceRoot, current)) { + dirs.add(current); + } + const parent = dirname(current); + if (parent === current) break; + current = parent; + } + } + return [...dirs].sort(); +} + +function copySupportFiles( + fs: MemoryFileSystem, + sourceRoot: string, + dir: string, +): number { + const relDir = relative(sourceRoot, dir); + const destDir = relDir ? `/php-src/${relDir}` : "/php-src"; + ensureDirRecursive(fs, destDir); + let count = 0; + for (const entry of readdirSync(dir)) { + if (!isSupportFileName(entry)) continue; + const relPath = relDir ? `${relDir}/${entry}` : entry; + if (shouldExclude(sourceRoot, relPath)) continue; + const full = join(dir, entry); + const st = lstatSync(full); + const dest = `${destDir}/${entry}`; + if (st.isSymbolicLink()) { + fs.symlink(readlinkSync(full), dest); + count++; + } else if (st.isFile()) { + writeVfsBinary( + fs, + dest, + new Uint8Array(readFileSync(full)), + st.mode & 0o7777, + ); + count++; + } + } + return count; +} + +function shouldExclude(sourceRoot: string, relPath: string): boolean { + const base = relPath.split("/").pop() ?? relPath; + if (relPath.includes("/.git/") || relPath.includes("/.deps/") || relPath.includes("/.libs/")) return true; + if (base.startsWith(".nfs")) return true; + if (isGeneratedPhptArtifact(sourceRoot, relPath)) return true; + if (base.endsWith(".o") || base.endsWith(".lo") || base.endsWith(".la") || base.endsWith(".a")) return true; + if (base === "php" || base === "phpdbg" || base === "php-cgi" || base === "php-fpm") { + try { + const st = statSync(join(sourceRoot, relPath)); + return st.size > 1024 * 1024; + } catch { + return true; + } + } + return false; +} + +function isGeneratedPhptArtifact(sourceRoot: string, relPath: string): boolean { + const slash = relPath.lastIndexOf("/"); + const dir = slash >= 0 ? relPath.slice(0, slash) : ""; + const base = slash >= 0 ? relPath.slice(slash + 1) : relPath; + + // Some PHPTs create a same-stem directory next to the test and then remove + // it from --CLEAN--. If a long browser run is interrupted during the test, + // the source checkout/cache can retain a huge generated directory; baking it + // into the immutable browser VFS changes the next run's initial state. Keep + // small same-stem directories because upstream also uses that convention for + // legitimate helper fixtures (for example ext/phar/tests/bug53872/). + if (base && existsSync(join(sourceRoot, dir, `${base}.phpt`))) { + try { + const full = join(sourceRoot, relPath); + const st = statSync(full); + if (st.isDirectory() && readdirSync(full).length >= 100) { + return true; + } + } catch { + // Fall through to the file-artifact checks below. + } + } + + for (const suffix of [".skip.php", ".clean.php", ".php"]) { + if (!base.endsWith(suffix)) continue; + const stem = base.slice(0, -suffix.length); + if (stem && existsSync(join(sourceRoot, dir, `${stem}.phpt`))) return true; + } + + // Same-stem archives and databases are often committed PHPT fixtures. The + // staging-copy lifecycle prevents this builder from contaminating its source + // tree, so filename heuristics must not discard those legitimate inputs. + return false; +} + +async function main() { + if (!existsSync(PHP_WASM)) { + throw new Error(`PHP wasm not found at ${PHP_WASM}. Run: bash packages/registry/php/build-php.sh`); + } + if (!ROOTFS_VFS || !existsSync(ROOTFS_VFS)) { + throw new Error( + `rootfs.vfs not found at ${ROOTFS_VFS}. Build the rootfs package or set ROOTFS_VFS`, + ); + } + if (INTL_SO && !ICU_RUNTIME) { + throw new Error( + "PHP intl.so is present but the declared php:icu.dat runtime file is not materialized", + ); + } + const phpSourceInput = resolvePhpSource(); + if (!existsSync(phpSourceInput)) { + throw new Error(`php-src not found at ${phpSourceInput}`); + } + const fingerprint = phpTestVfsFingerprint(phpSourceInput); + if (process.argv.includes("--print-fingerprint")) { + process.stdout.write(`${fingerprint}\n`); + return; + } + const stagingRoot = mkdtempSync(join(tmpdir(), "kandelo-php-vfs-source-")); + const phpSrc = join(stagingRoot, "php-src"); + try { + cpSync(phpSourceInput, phpSrc, { + recursive: true, + dereference: false, + filter: (path) => { + const base = path.split(/[\\/]/).pop(); + return base !== ".git" && base !== ".deps" && base !== ".libs"; + }, + }); + preparePhpTestFixtures(phpSrc, PHP_FIXTURE_ROOT); + + console.log("==> Building PHP PHPT test VFS image"); + console.log(` php-src input: ${phpSourceInput}`); + + let fs = MemoryFileSystem.fromImage( + new Uint8Array(readFileSync(ROOTFS_VFS)), + { maxByteLength: FS_MAX_BYTES }, + ); + const baseStats = fs.statfs("/"); + const baseMaxBytes = baseStats.blocks * baseStats.bsize; + if (baseMaxBytes < FS_MAX_BYTES) { + console.log( + ` Rebasing rootfs capacity from ${Math.round(baseMaxBytes / 1024 / 1024)} MiB ` + + `to ${Math.round(FS_MAX_BYTES / 1024 / 1024)} MiB...`, + ); + fs = fs.rebaseToNewFileSystem(FS_MAX_BYTES); + } + ensureDirRecursive(fs, "/usr/local/bin"); + ensureDirRecursive(fs, "/usr/local/sbin"); + ensureDirRecursive(fs, "/usr/lib/php/extensions"); + ensureDirRecursive(fs, "/php-src"); + + writeVfsBinary(fs, "/usr/local/bin/php", new Uint8Array(readFileSync(PHP_WASM))); + if (PHP_FPM_WASM && existsSync(PHP_FPM_WASM)) { + writeVfsBinary( + fs, + "/usr/local/sbin/php-fpm", + new Uint8Array(readFileSync(PHP_FPM_WASM)), + ); + } + for (const extensionDir of PHP_EXTENSION_DIRS) { + if (!existsSync(extensionDir)) continue; + for (const entry of readdirSync(extensionDir)) { + if (!entry.endsWith(".so")) continue; + const src = join(extensionDir, entry); + writeVfsBinary( + fs, + `/usr/lib/php/extensions/${entry}`, + new Uint8Array(readFileSync(src)), + ); + } + } + if (OPCACHE_SO && existsSync(OPCACHE_SO)) { + // PHP_OPCACHE_SO is the explicit harness override for the OPcache side + // module. Honor it even when PHP_EXTENSION_DIR also contains an + // opcache.so; otherwise browser PHPT runs can silently package a stale + // or non-side-module opcache under the canonical extension path while the + // runner advertises OPcache as available. + writeVfsBinary( + fs, + "/usr/lib/php/extensions/opcache.so", + new Uint8Array(readFileSync(OPCACHE_SO)), + ); + } + if (INTL_SO && ICU_RUNTIME) { + writeVfsBinary( + fs, + "/usr/lib/php/extensions/intl.so", + new Uint8Array(readFileSync(INTL_SO)), + 0o755, + ); + ensureDirRecursive(fs, dirname(ICU_RUNTIME.guestPath)); + writeVfsBinary( + fs, + ICU_RUNTIME.guestPath, + new Uint8Array(readFileSync(ICU_RUNTIME.hostPath)), + ICU_RUNTIME.mode, + ); + } + + const phptDirs = collectPhptDirs(phpSrc); + const supportDirs = collectPhptSupportDirs(phpSrc, phptDirs); + console.log(` Writing ${phptDirs.length} PHPT directories...`); + let fileCount = 0; + for (const dir of phptDirs) { + const rel = relative(phpSrc, dir); + const dest = rel ? `/php-src/${rel}` : "/php-src"; + ensureDirRecursive(fs, dirname(dest)); + fileCount += walkAndWrite(fs, dir, dest, { + exclude: (childRel) => shouldExclude(phpSrc, rel ? `${rel}/${childRel}` : childRel), + preserveMode: true, + preserveSymlinks: true, + failOnError: true, + }); + } + if (supportDirs.length > 0) { + console.log(` Writing ${supportDirs.length} PHPT support directories...`); + for (const dir of supportDirs) { + fileCount += copySupportFiles(fs, phpSrc, dir); + } + } + console.log(` ${fileCount} files`); + + await saveImage(fs, OUT_FILE); + writeFileSync( + META_FILE, + `${JSON.stringify( + { + version: 1, + fingerprint, + generatedAt: new Date().toISOString(), + }, + null, + 2, + )}\n`, + ); + } finally { + rmSync(stagingRoot, { recursive: true, force: true }); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/images/vfs/scripts/dinit-image-helpers.ts b/images/vfs/scripts/dinit-image-helpers.ts index d220ce0339..ae10aedd85 100644 --- a/images/vfs/scripts/dinit-image-helpers.ts +++ b/images/vfs/scripts/dinit-image-helpers.ts @@ -146,12 +146,23 @@ const ETC_HOSTS = [ "", ].join("\n"); -const ETC_SERVICES = [ - "http\t\t80/tcp\t\twww", - "https\t\t443/tcp", - "mysql\t\t3306/tcp", - "", -].join("\n"); +const ETC_SERVICES = readFileSync( + join(REPO_ROOT, "images", "rootfs", "etc", "services"), + "utf8", +); + +/** + * Install the account and network databases shared by dinit-based images. + * `/etc/services` comes from the rootfs source so derived images cannot drift + * into a second, smaller service-name contract. + */ +export function addDinitBaseSystemFiles(fs: MemoryFileSystem): void { + ensureDirRecursive(fs, "/etc"); + writeVfsFile(fs, "/etc/passwd", ETC_PASSWD); + writeVfsFile(fs, "/etc/group", ETC_GROUP); + writeVfsFile(fs, "/etc/hosts", ETC_HOSTS); + writeVfsFile(fs, "/etc/services", ETC_SERVICES); +} /** * Options for {@link addDinitInit}. The defaults set up an implicit @@ -264,11 +275,7 @@ export function addDinitInit( // Basic rootfs files. Most Unix daemons expect these to exist at // startup; missing them is the usual cause of "started but exits 1 // silently" failures. - ensureDirRecursive(fs, "/etc"); - writeVfsFile(fs, "/etc/passwd", ETC_PASSWD); - writeVfsFile(fs, "/etc/group", ETC_GROUP); - writeVfsFile(fs, "/etc/hosts", ETC_HOSTS); - writeVfsFile(fs, "/etc/services", ETC_SERVICES); + addDinitBaseSystemFiles(fs); // Standard runtime/log dirs ensureDirRecursive(fs, "/var/log"); diff --git a/images/vfs/scripts/mariadb-image-helpers.ts b/images/vfs/scripts/mariadb-image-helpers.ts new file mode 100644 index 0000000000..4d5cb2aaa6 --- /dev/null +++ b/images/vfs/scripts/mariadb-image-helpers.ts @@ -0,0 +1,29 @@ +import type { MemoryFileSystem } from "../../../host/src/vfs/memory-fs"; +import { ensureDirRecursive } from "../../../host/src/vfs/image-helpers"; + +export const MARIADB_UID = 101; +export const MARIADB_GID = 101; +export const MARIADB_DATA_MODE = 0o775; +export const MARIADB_DATA_DIRS = [ + "/data", + "/data/mysql", + "/data/tmp", + "/data/test", +] as const; + +/** + * Create the writable filesystem state used by mariadbd after --user=mysql + * drops the process to uid/gid 101. The group-writable 0775 mode keeps the + * ownership contract explicit without making database state world-writable; + * /tmp retains ordinary POSIX sticky-directory semantics. + */ +export function prepareMariadbWritableDirectories(fs: MemoryFileSystem): void { + for (const dir of MARIADB_DATA_DIRS) { + ensureDirRecursive(fs, dir); + fs.chown(dir, MARIADB_UID, MARIADB_GID); + fs.chmod(dir, MARIADB_DATA_MODE); + } + + ensureDirRecursive(fs, "/tmp"); + fs.chmod("/tmp", 0o1777); +} diff --git a/images/vfs/scripts/php-test-fixtures.ts b/images/vfs/scripts/php-test-fixtures.ts new file mode 100644 index 0000000000..382c9061b1 --- /dev/null +++ b/images/vfs/scripts/php-test-fixtures.ts @@ -0,0 +1,260 @@ +import { + cpSync, + existsSync, + readFileSync, + readdirSync, + writeFileSync, +} from "node:fs"; +import { createHash } from "node:crypto"; +import { join } from "node:path"; + +const NOPHAR_CRC_MASK = Buffer.from("0xffffffff", "ascii"); +const NOPHAR_SIGNED_CRC_MASK = Buffer.from("(-1) ", "ascii"); + +function patchNoPharFixture(sourceRoot: string): void { + const path = join(sourceRoot, "ext/phar/tests/files/nophar.phar"); + if (!existsSync(path)) return; + + const bytes = readFileSync(path); + if (bytes.length < 28) { + throw new Error( + `Unable to patch PHP no-phar fixture: truncated archive ${path}`, + ); + } + + const signatureOffset = bytes.length - 28; + const algorithmOffset = bytes.length - 8; + const magicOffset = bytes.length - 4; + const originalMaskOffset = bytes.indexOf(NOPHAR_CRC_MASK, 0); + const hasOriginalMask = + originalMaskOffset >= 0 && originalMaskOffset < signatureOffset; + if (!hasOriginalMask) return; + if (!bytes.subarray(magicOffset).equals(Buffer.from("GBMB", "ascii"))) { + throw new Error( + `Unable to patch PHP no-phar fixture: missing signature magic in ${path}`, + ); + } + if (bytes.readUInt32LE(algorithmOffset) !== 2) { + throw new Error( + `Unable to patch PHP no-phar fixture: expected a SHA1 signature in ${path}`, + ); + } + + let offset = 0; + while (offset < signatureOffset) { + const found = bytes.indexOf(NOPHAR_CRC_MASK, offset); + if (found < 0 || found >= signatureOffset) break; + NOPHAR_SIGNED_CRC_MASK.copy(bytes, found); + offset = found + NOPHAR_SIGNED_CRC_MASK.length; + } + + createHash("sha1") + .update(bytes.subarray(0, signatureOffset)) + .digest() + .copy(bytes, signatureOffset); + writeFileSync(path, bytes); +} + +export function preparePhpTestFixtures( + sourceRoot: string, + fixtureRoot: string, +): void { + // PHP 8.3.15's upstream SNI PHPT fixtures expired on 2026-04-02. Do not + // fake guest time to make them pass: that would compromise Kandelo as a + // general POSIX platform. Instead, treat this as test-fixture maintenance + // and copy equivalent long-lived certificates into the local test tree + // before discovery/VFS packaging. + const fixtureDir = join(fixtureRoot, "openssl-sni-2036"); + const destDir = join(sourceRoot, "ext/openssl/tests"); + if (existsSync(fixtureDir) && existsSync(destDir)) { + for (const entry of readdirSync(fixtureDir)) { + if (!entry.startsWith("sni_server_") || !entry.endsWith(".pem")) continue; + cpSync(join(fixtureDir, entry), join(destDir, entry)); + } + } + + // The upstream no-phar fixture embeds a fixed-offset PHP stub whose + // `0xffffffff` CRC mask is a float on wasm32. PHP 8.3 then emits E_DEPRECATED + // before the expected fixture output. Keep the replacement byte-width + // stable and refresh the fixture's SHA1 phar signature. This belongs in the + // shared test-source preparation path so clean package builds, Node PHPTs, + // and browser VFS images all exercise the same maintained fixture. + patchNoPharFixture(sourceRoot); + + // PHP 8.3.15's FPM test fixtures need small harness-side maintenance under + // Kandelo: + // - ext/opcache/tests/preload_user_004.phpt calls FPM\Tester::getLogLines(), + // but the shipped FPM tester helper does not define that method. + // - logreader.inc has a native three-second default that is too short for + // OPcache preload startup under emulation. + // - fcgi.inc has a native five-second client read/write timeout; under + // wasm emulation, OPcache preload requests can legitimately take longer + // while still producing the correct FastCGI response. + // + // These changes only affect the copied PHPT fixture tree used by the + // harness. They do not change PHP runtime behavior or Kandelo kernel + // behavior. + const fpmTester = join(sourceRoot, "sapi/fpm/tests/tester.inc"); + if (existsSync(fpmTester)) { + const text = readFileSync(fpmTester, "utf8"); + if (text.includes("class Tester")) { + const marker = " /**\n * Expect no log lines to be logged.\n"; + const method = ` /**\n * Return currently available FPM log lines.\n *\n * @param int $timeoutSeconds Seconds to wait for the first line.\n * @param int $timeoutMicroseconds Additional microseconds to wait for the first line.\n *\n * @return array\n * @throws \\Exception\n */\n public function getLogLines(int $timeoutSeconds = 3, int $timeoutMicroseconds = 0): array\n {\n $configuredTimeout = getenv('TEST_FPM_LOG_TIMEOUT_SECONDS');\n if ($configuredTimeout !== false && is_numeric($configuredTimeout)) {\n $timeoutSeconds = max($timeoutSeconds, (int) $configuredTimeout);\n }\n\n $lines = [];\n $line = $this->logReader->getLine($timeoutSeconds, $timeoutMicroseconds);\n while ($line !== null) {\n if ($line !== '') {\n $lines[] = $line;\n }\n $line = $this->logReader->getLine(timeoutSeconds: 0, timeoutMicroseconds: 1000);\n }\n\n return $lines;\n }\n\n`; + let next = text; + if (text.includes("function getLogLines(")) { + const start = text.indexOf(" /**\n * Return currently available FPM log lines."); + const end = text.indexOf(marker, start); + if (start < 0 || end <= start) { + throw new Error( + `Unable to update PHP FPM tester fixture: getLogLines block not found in ${fpmTester}`, + ); + } + next = text.slice(0, start) + method + text.slice(end); + } else { + if (!text.includes(marker)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: marker not found in ${fpmTester}`, + ); + } + next = text.replace(marker, method + marker); + } + if (!next.includes("TEST_FPM_CHECK_CONNECTION_ATTEMPTS")) { + const from = ` ) {\n $i = 0;\n do {`; + const to = ` ) {\n $configuredAttempts = getenv('TEST_FPM_CHECK_CONNECTION_ATTEMPTS');\n if ($configuredAttempts !== false && is_numeric($configuredAttempts)) {\n $attempts = max($attempts, (int) $configuredAttempts);\n }\n\n $i = 0;\n do {`; + if (!next.includes(from)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: checkConnection marker not found in ${fpmTester}`, + ); + } + next = next.replace(from, to); + } + if (!next.includes("$cmd .= ' --allow-to-run-as-root';")) { + const from = `$cmd = self::findExecutable() . " -n $configTestArg -y $configFile 2>&1";`; + const to = `$cmd = self::findExecutable() . " -n $configTestArg -y $configFile";\n if (getenv('TEST_FPM_RUN_AS_ROOT')) {\n $cmd .= ' --allow-to-run-as-root';\n }\n $cmd .= " 2>&1";`; + if (!next.includes(from)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: testConfig command marker not found in ${fpmTester}`, + ); + } + next = next.replace(from, to); + } + if (!next.includes("file_exists($extensionDir . '/' . $extension . '.so')")) { + const from = ` foreach ($extensions as $extension) {\n $cmd[] = '-dextension=' . $extension;\n }`; + const to = ` foreach ($extensions as $extension) {\n if (file_exists($extensionDir . '/' . $extension . '.so')) {\n $cmd[] = '-dextension=' . $extension;\n }\n }`; + if (!next.includes(from)) { + throw new Error( + `Unable to patch PHP FPM tester fixture: extension loading marker not found in ${fpmTester}`, + ); + } + next = next.replace(from, to); + } + if (next !== text) writeFileSync(fpmTester, next, "utf8"); + } + } + + const fpmLogReader = join(sourceRoot, "sapi/fpm/tests/logreader.inc"); + if (existsSync(fpmLogReader)) { + const text = readFileSync(fpmLogReader, "utf8"); + if (!text.includes("TEST_FPM_LOG_TIMEOUT_SECONDS")) { + const from = `if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) {\n $timeoutSeconds = 3;\n $timeoutMicroseconds = 0;\n }`; + const to = `if (is_null($timeoutSeconds) && is_null($timeoutMicroseconds)) {\n $configuredTimeout = getenv('TEST_FPM_LOG_TIMEOUT_SECONDS');\n $timeoutSeconds = $configuredTimeout !== false && is_numeric($configuredTimeout)\n ? max(3, (int) $configuredTimeout)\n : 3;\n $timeoutMicroseconds = 0;\n }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP FPM logreader fixture: marker not found in ${fpmLogReader}`, + ); + } + writeFileSync(fpmLogReader, text.replace(from, to), "utf8"); + } + } + + const fpmFcgi = join(sourceRoot, "sapi/fpm/tests/fcgi.inc"); + if (existsSync(fpmFcgi)) { + const text = readFileSync(fpmFcgi, "utf8"); + if (!text.includes("TEST_FPM_READ_WRITE_TIMEOUT_MS")) { + const from = ` $this->transport = $transport;\n }`; + const to = ` $this->transport = $transport;\n\n $configuredTimeout = getenv('TEST_FPM_READ_WRITE_TIMEOUT_MS');\n if ($configuredTimeout !== false && is_numeric($configuredTimeout)) {\n $this->_readWriteTimeout = max($this->_readWriteTimeout, (int) $configuredTimeout);\n }\n }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP FPM FastCGI fixture: constructor marker not found in ${fpmFcgi}`, + ); + } + writeFileSync(fpmFcgi, text.replace(from, to), "utf8"); + } + } + + const fpmIpv4Fallback = join(sourceRoot, "sapi/fpm/tests/socket-ipv4-fallback.phpt"); + if (existsSync(fpmIpv4Fallback)) { + const text = readFileSync(fpmIpv4Fallback, "utf8"); + const from = "Address already in use \\(\\d+\\)"; + const to = "Address (?:already )?in use \\(\\d+\\)"; + if (text.includes(from) && !text.includes(to)) { + // musl's strerror(EADDRINUSE) is "Address in use" while glibc's is + // "Address already in use". Both describe the same POSIX errno, so make + // this fixture regex libc-portable rather than changing Kandelo/libc + // message strings to match one C library. + writeFileSync(fpmIpv4Fallback, text.replace(from, to), "utf8"); + } + } + + const mysqliFakeServer = join(sourceRoot, "ext/mysqli/tests/fake_server.inc"); + if (existsSync(mysqliFakeServer)) { + const text = readFileSync(mysqliFakeServer, "utf8"); + if (!text.includes("MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS")) { + const from = ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`; + const to = ` public function read($bytes_len = 1024) + { + // wait 20ms to fill the buffer + usleep(20000); + $data = fread($this->conn, $bytes_len); + + if ($data && $bytes_len > 1024) { + // Large reads in this fake MySQL server are used to drain the + // connection tail after the client reacts to a crafted packet. + // fread() on a POSIX stream may return as soon as any bytes are + // available; it is not required to wait for later client writes to + // coalesce into the same TCP segment. Native php-src runs usually + // see the final COM_STMT_CLOSE and COM_QUIT together after the + // fixed sleep above, but the browser host can schedule the guest + // peer more slowly. Keep draining for a short idle window and print + // one Received line so the fixture remains semantically identical + // without relying on transport coalescing. + $idleMs = getenv('MYSQLI_FAKE_SERVER_DRAIN_IDLE_MS'); + $idleMs = $idleMs !== false && is_numeric($idleMs) ? max(0, (int) $idleMs) : 250; + $deadline = microtime(true) + ($idleMs / 1000); + $wasBlocking = stream_get_meta_data($this->conn)['blocked'] ?? true; + stream_set_blocking($this->conn, false); + try { + while (strlen($data) < $bytes_len && microtime(true) < $deadline) { + usleep(10000); + $chunk = fread($this->conn, $bytes_len - strlen($data)); + if ($chunk !== false && $chunk !== '') { + $data .= $chunk; + $deadline = microtime(true) + ($idleMs / 1000); + } + } + } finally { + stream_set_blocking($this->conn, $wasBlocking); + } + } + + if ($data) { + fprintf(STDERR, "[*] Received: %s\\n", bin2hex($data)); + } + }`; + if (!text.includes(from)) { + throw new Error( + `Unable to patch PHP mysqli fake_server fixture: read() marker not found in ${mysqliFakeServer}`, + ); + } + writeFileSync(mysqliFakeServer, text.replace(from, to), "utf8"); + } + } +} diff --git a/images/vfs/scripts/shell-vfs-build.ts b/images/vfs/scripts/shell-vfs-build.ts index 969c554eb9..fb1087c427 100644 --- a/images/vfs/scripts/shell-vfs-build.ts +++ b/images/vfs/scripts/shell-vfs-build.ts @@ -162,34 +162,6 @@ function populateSystem(fs: MemoryFileSystem): void { fs.chown("/home/user", 1000, 1000); populateNetHackPlayground(fs); - // /etc/services — required for getservbyname/getservbyport calls in - // nginx/php-fpm/MariaDB. Harmless in Shell-only builds. - const services = [ - "tcpmux\t\t1/tcp", - "echo\t\t7/tcp", - "echo\t\t7/udp", - "discard\t\t9/tcp\t\tsink null", - "discard\t\t9/udp\t\tsink null", - "ftp-data\t20/tcp", - "ftp\t\t21/tcp", - "ssh\t\t22/tcp", - "telnet\t\t23/tcp", - "smtp\t\t25/tcp\t\tmail", - "domain\t\t53/tcp", - "domain\t\t53/udp", - "http\t\t80/tcp\t\twww", - "pop3\t\t110/tcp\t\tpop-3", - "nntp\t\t119/tcp\t\treadnews untp", - "ntp\t\t123/udp", - "imap\t\t143/tcp\t\timap2", - "snmp\t\t161/udp", - "https\t\t443/tcp", - "imaps\t\t993/tcp", - "pop3s\t\t995/tcp", - "mysql\t\t3306/tcp", - ].join("\n") + "\n"; - writeVfsFile(fs, "/etc/services", services); - const gitconfig = [ "[maintenance]", "\tauto = false", diff --git a/images/vfs/scripts/vfs-image-helpers.ts b/images/vfs/scripts/vfs-image-helpers.ts index 8c383ccb2a..a01f6049c7 100644 --- a/images/vfs/scripts/vfs-image-helpers.ts +++ b/images/vfs/scripts/vfs-image-helpers.ts @@ -3,7 +3,14 @@ * from host/src/vfs/image-helpers.ts so demo runtime code can share them. * The Node-only helpers (host-disk walk, save-to-file) live here. */ -import { readFileSync, readdirSync, lstatSync, writeFileSync, mkdirSync } from "fs"; +import { + readFileSync, + readdirSync, + readlinkSync, + lstatSync, + writeFileSync, + mkdirSync, +} from "fs"; import { join, relative } from "path"; import { zstdCompressSync, constants as zlibConstants } from "node:zlib"; import type { @@ -25,6 +32,9 @@ import { writeVfsBinary, ensureDirRecursive } from "../../../host/src/vfs/image- export interface WalkOptions { exclude?: (relPath: string) => boolean; + preserveMode?: boolean; + preserveSymlinks?: boolean; + failOnError?: boolean; } /** @@ -48,18 +58,29 @@ export function walkAndWrite( try { const lstat = lstatSync(full); - if (lstat.isSymbolicLink()) continue; - if (lstat.isDirectory()) { - if (opts?.exclude?.(rel)) continue; + if (opts?.exclude?.(rel)) continue; + if (lstat.isSymbolicLink()) { + if (opts?.preserveSymlinks) { + ensureDirRecursive(fs, mountPath.slice(0, mountPath.lastIndexOf("/")) || "/"); + fs.symlink(readlinkSync(full), mountPath); + count++; + } + } else if (lstat.isDirectory()) { ensureDirRecursive(fs, mountPath); + if (opts?.preserveMode) fs.chmod(mountPath, lstat.mode & 0o7777); walk(full); } else if (lstat.isFile()) { - if (opts?.exclude?.(rel)) continue; const data = readFileSync(full); - writeVfsBinary(fs, mountPath, new Uint8Array(data), 0o644); + writeVfsBinary( + fs, + mountPath, + new Uint8Array(data), + opts?.preserveMode ? lstat.mode & 0o7777 : 0o644, + ); count++; } - } catch { + } catch (err) { + if (opts?.failOnError) throw err; // Skip unreadable files } } @@ -146,8 +167,10 @@ function assertNoStaleWasmArtifacts(fs: MemoryFileSystem, kernelAbi: number): vo continue; } if (!isWasm(bytes)) continue; + const artifactBytes = new Uint8Array(bytes.byteLength); + artifactBytes.set(bytes); const reasons = describeWasmArtifactPolicyFailures( - bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength), + artifactBytes.buffer, { expectedAbi: kernelAbi }, ); if (reasons.length > 0) failures.push(`${path}: ${reasons.join("; ")}`); diff --git a/libc/glue/abi_constants.h b/libc/glue/abi_constants.h index abd654be80..b2d5bd8826 100644 --- a/libc/glue/abi_constants.h +++ b/libc/glue/abi_constants.h @@ -4,9 +4,12 @@ #define WASM_POSIX_ABI_CONSTANTS_H /* Mirrors wasm_posix_shared::ABI_VERSION. */ -#define WASM_POSIX_ABI_VERSION 18u +#define WASM_POSIX_ABI_VERSION 39u /* Default process-wasm pthread slot declaration. */ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT -1 +/* Fixed kernel/musl resource-usage wire record size. */ +#define WASM_POSIX_RUSAGE_WIRE_SIZE 144u + #endif /* WASM_POSIX_ABI_CONSTANTS_H */ diff --git a/libc/glue/channel_syscall.c b/libc/glue/channel_syscall.c index d417dfbea1..b0bff7f8af 100644 --- a/libc/glue/channel_syscall.c +++ b/libc/glue/channel_syscall.c @@ -102,10 +102,30 @@ int *__errno_location(void); #define CH_SIG_ALT_SIZE (CH_SIG_BASE + 40) #define SA_SIGINFO 4 +#define SA_RESTART 0x10000000u +#define EFAULT 14 +#define EINTR 4 +#define EINVAL 22 +#define SYS_SIGACTION 36 +#define SYS_WAIT4 139 +#define SYS_WAITID 288 #define SYS_SIGPROCMASK 37 #define SYS_RT_SIGRETURN 208 #define SIG_SETMASK 2 +/* The kernel ABI deliberately keeps sigaction's transport record fixed at + * 16 bytes: u32 table index, u32 flags, u64 mask. musl's internal + * k_sigaction happens to match that prefix on wasm32, while its pointer and + * unsigned-long fields make the memory64 form 32 bytes. */ +struct kandelo_sigaction_wire { + uint32_t handler; + uint32_t flags; + uint64_t mask; +}; + +_Static_assert(sizeof(struct kandelo_sigaction_wire) == 16, + "sigaction wire record must stay 16 bytes"); + /* Per-thread channel base address. * * Stored as an imported WebAssembly global — each wasm instance (thread) @@ -157,6 +177,9 @@ uintptr_t __get_channel_base_addr(void) { __attribute__((import_module("kernel"), import_name("kernel_fork"))) int32_t kernel_fork(void); +__attribute__((import_module("kernel"), import_name("kernel_exit"))) +_Noreturn void kernel_exit(int32_t status); + /* Direct fork/vfork/_Fork — call kernel_fork without going through the * general syscall dispatcher. This ensures fork instrumentation only covers * fork callers, not every function that makes any syscall. */ @@ -206,15 +229,16 @@ int vfork(void) /* Forward declaration */ static long __do_syscall(long n, long long a1, long long a2, long long a3, long long a4, long long a5, long long a6); +extern long __syscall_cp_check(long r); -static void __deliver_pending_signal(uintptr_t base) +static uint32_t __deliver_pending_signal(uintptr_t base) { uint32_t *sig_signum_ptr = (uint32_t *)(uintptr_t)(base + CH_SIG_SIGNUM); uint32_t *sig_handler_ptr = (uint32_t *)(uintptr_t)(base + CH_SIG_HANDLER); uint32_t *sig_flags_ptr = (uint32_t *)(uintptr_t)(base + CH_SIG_FLAGS); uint32_t signum = *sig_signum_ptr; - if (signum == 0) return; + if (signum == 0) return 0; /* Cooperative hard-exit for host teardown. * @@ -289,16 +313,17 @@ static void __deliver_pending_signal(uintptr_t base) int32_t si_code = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_CODE); int32_t si_pid = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_PID); int32_t si_uid = *(int32_t *)(uintptr_t)(base + CH_SIG_SI_UID); - /* siginfo_t layout (128 bytes): - * [0] si_signo, [4] si_errno, [8] si_code, - * [12] si_pid, [16] si_uid, [20] si_value.sival_int */ + /* siginfo_t's payload union aligns to long: offset 12 on wasm32 and + * 16 on wasm64. pid/uid occupy its first pair and si_value/si_status + * occupies the following union member. */ + const uint32_t fields_offset = __SIZEOF_POINTER__ == 8 ? 16 : 12; char siginfo_buf[128]; __builtin_memset(siginfo_buf, 0, sizeof(siginfo_buf)); *(int *)(siginfo_buf + 0) = (int)signum; /* si_signo */ *(int *)(siginfo_buf + 8) = si_code; /* si_code */ - *(int *)(siginfo_buf + 12) = si_pid; /* si_pid */ - *(int *)(siginfo_buf + 16) = si_uid; /* si_uid */ - *(int *)(siginfo_buf + 20) = si_value_int; /* si_value.sival_int */ + *(int *)(siginfo_buf + fields_offset) = si_pid; /* si_pid */ + *(int *)(siginfo_buf + fields_offset + 4) = si_uid; /* si_uid */ + *(int *)(siginfo_buf + fields_offset + 8) = si_value_int; void (*sa)(int, void *, void *) = (void (*)(int, void *, void *))(uintptr_t)handler; sa((int)signum, (void *)siginfo_buf, (void *)0); @@ -321,14 +346,17 @@ static void __deliver_pending_signal(uintptr_t base) * (the kernel writes signal info on the sigprocmask return). */ __do_syscall(SYS_SIGPROCMASK, SIG_SETMASK, (long)(uintptr_t)&old_mask, 0, 8, 0, 0); + + return flags; } /* ------------------------------------------------------------------ */ /* Central dispatch — writes to channel and blocks for result */ /* ------------------------------------------------------------------ */ -static long __do_syscall(long n, long long a1, long long a2, long long a3, - long long a4, long long a5, long long a6) +static long __do_syscall_impl(long n, long long a1, long long a2, long long a3, + long long a4, long long a5, long long a6, + int cancellation_point) { /* Fork/vfork are handled by fork()/_Fork()/vfork() overrides above, * which call kernel_fork() directly. If we somehow get here (e.g. a @@ -338,6 +366,57 @@ static long __do_syscall(long n, long long a1, long long a2, long long a3, return -38; /* ENOSYS */ } + /* Per-thread exit is non-returning. Route it through the dedicated import + * so worker-main can record the status and unwind the Wasm entry after the + * channel completes. Returning through the generic channel path lets + * musl's mandatory SYS_exit retry loop park a second time on a channel the + * host has already removed, leaving a stale waiter when the pthread slot is + * reused. Keep exit_group on the generic path: the host must see that + * distinct syscall so exit() from a pthread still terminates the process. */ + if (n == SYS_EXIT) { + kernel_exit((int32_t)a1); + } + +#if __SIZEOF_POINTER__ == 8 + struct kandelo_sigaction_wire sigaction_in_wire; + struct kandelo_sigaction_wire sigaction_out_wire; + uintptr_t sigaction_old_guest = 0; + int translate_sigaction = n == SYS_SIGACTION; + + if (translate_sigaction) { + const uintptr_t memory_bytes = + (uintptr_t)__builtin_wasm_memory_size(0) * 65536u; + if (a2 != 0) { + const uintptr_t act = (uintptr_t)a2; + if (act > memory_bytes || 24u > memory_bytes - act) + return -EFAULT; + + uint64_t handler; + uint64_t flags; + __builtin_memcpy(&handler, (const void *)act, sizeof(handler)); + __builtin_memcpy(&flags, (const void *)(act + 8), sizeof(flags)); + __builtin_memcpy( + &sigaction_in_wire.mask, + (const void *)(act + 16), + sizeof(sigaction_in_wire.mask) + ); + if (handler > UINT32_MAX || flags > UINT32_MAX) + return -EINVAL; + sigaction_in_wire.handler = (uint32_t)handler; + sigaction_in_wire.flags = (uint32_t)flags; + a2 = (long long)(uintptr_t)&sigaction_in_wire; + } + if (a3 != 0) { + sigaction_old_guest = (uintptr_t)a3; + if (sigaction_old_guest > memory_bytes || + 24u > memory_bytes - sigaction_old_guest) + return -EFAULT; + __builtin_memset(&sigaction_out_wire, 0, sizeof(sigaction_out_wire)); + a3 = (long long)(uintptr_t)&sigaction_out_wire; + } + } +#endif + /* IMPORTANT: In multi-threaded wasm programs (like BEAM), all threads * share the same linear memory. The compiler may spill local variables * to the shadow stack (linear memory). If another thread's pointer @@ -348,7 +427,13 @@ static long __do_syscall(long n, long long a1, long long a2, long long a3, * in a local variable that might be spilled to the shadow stack. * The wasm global is per-instance and immune to cross-thread corruption. */ - uintptr_t base = get_channel_base(); + uintptr_t base; + long result; + int32_t err; + uint32_t delivered_flags; + +restart_wait_syscall: + base = get_channel_base(); /* Write syscall number and arguments directly using base offsets. * These are one-shot writes — if the shadow stack value of 'base' is @@ -401,19 +486,63 @@ static long __do_syscall(long n, long long a1, long long a2, long long a3, /* Read result — re-read base from global for safety */ base = get_channel_base(); - long result = (long)*(int64_t *)(uintptr_t)(base + CH_RETURN); - int32_t err = *(int32_t *)(uintptr_t)(base + CH_ERRNO); + result = (long)*(int64_t *)(uintptr_t)(base + CH_RETURN); + err = *(int32_t *)(uintptr_t)(base + CH_ERRNO); /* Reset status to IDLE for next syscall */ __c11_atomic_store((_Atomic int32_t *)(uintptr_t)(base + CH_STATUS), CH_IDLE, __ATOMIC_SEQ_CST); +#if __SIZEOF_POINTER__ == 8 + if (translate_sigaction && sigaction_old_guest != 0 && err == 0) { + const uint64_t handler = sigaction_out_wire.handler; + const uint64_t flags = sigaction_out_wire.flags; + __builtin_memcpy( + (void *)sigaction_old_guest, + &handler, + sizeof(handler) + ); + __builtin_memcpy( + (void *)(sigaction_old_guest + 8), + &flags, + sizeof(flags) + ); + __builtin_memcpy( + (void *)(sigaction_old_guest + 16), + &sigaction_out_wire.mask, + sizeof(sigaction_out_wire.mask) + ); + } +#endif + /* Check for pending signal delivery from the kernel. * The kernel writes signal info to CH_SIG_* after each syscall if * a Handler signal is deliverable. We invoke the handler here, * synchronously before returning to the caller, matching POSIX * semantics (raise() doesn't return until signal handler completes). */ - __deliver_pending_signal(get_channel_base()); + delivered_flags = __deliver_pending_signal(get_channel_base()); + + /* wait4()/waitid() are host-deferred, so a caught signal completes the + * channel with EINTR in order to run its handler on this guest thread. + * SA_RESTART makes that interruption transparent: after the handler and + * mask restoration finish, submit the same wait operation again. Keep the + * retry list deliberately narrow; several other EINTR-returning calls have + * timeout/cancellation rules that forbid this generic treatment. */ + if (err == EINTR && (delivered_flags & SA_RESTART) != 0 && + (n == SYS_WAIT4 || n == SYS_WAITID)) { + /* __syscall_cp's outer cancellation check has not run yet. A signal + * handler may have enabled a cancellation that was already pending, + * or the host may have used this EINTR completion to wake a canceled + * stopped waiter. Honor that cancellation before submitting another + * indefinite wait. MASKED cancellation returns -ECANCELED; enabled + * cancellation exits through pthread_exit. */ + if (cancellation_point) { + long checked = __syscall_cp_check(-(long)EINTR); + if (checked != -(long)EINTR) + return checked; + } + goto restart_wait_syscall; + } /* Return in musl's expected format: negative errno on error. * musl's __syscall_ret() converts this to set errno and return -1. */ @@ -423,6 +552,12 @@ static long __do_syscall(long n, long long a1, long long a2, long long a3, return result; } +static long __do_syscall(long n, long long a1, long long a2, long long a3, + long long a4, long long a5, long long a6) +{ + return __do_syscall_impl(n, a1, a2, a3, a4, a5, a6, 0); +} + /* ================================================================== */ /* Public __syscallN entry points — musl calls these */ /* ================================================================== */ @@ -490,15 +625,12 @@ long __syscall6(long n, long long a1, long long a2, long long a3, long long a4, * wasm facility to preempt a running thread mid-computation. */ 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_impl(n, a1, a2, a3, a4, a5, a6, 1); return __syscall_cp_check(r); } diff --git a/libc/glue/syscall_glue.c b/libc/glue/syscall_glue.c index 2f6b9db481..fbc5dd7213 100644 --- a/libc/glue/syscall_glue.c +++ b/libc/glue/syscall_glue.c @@ -17,6 +17,7 @@ * - Return -38 (ENOSYS) for unrecognised syscall numbers */ +#include "abi_constants.h" #include "syscall_imports.h" /* ------------------------------------------------------------------ */ @@ -974,11 +975,11 @@ static long __do_syscall(long n, long a1, long a2, long a3, return (long)kernel_setrlimit((uint32_t)a1, (const uint8_t *)(uintptr_t)a2); - /* getrusage — (who, buf_ptr, buf_len) */ + /* getrusage — (who, buf_ptr) */ case SYS_GETRUSAGE: return (long)kernel_getrusage((int32_t)a1, (uint8_t *)(uintptr_t)a2, - (uint32_t)a3); + WASM_POSIX_RUSAGE_WIRE_SIZE); /* ============================================================== */ /* System info */ @@ -996,16 +997,18 @@ static long __do_syscall(long n, long a1, long a2, long a3, case SYS_SYSCONF: return (long)kernel_sysconf((int32_t)a1); - /* pathconf — (path, name) */ + /* pathconf — (path, name, int64_t *value) */ case SYS_PATHCONF: { const char *p = (const char *)(uintptr_t)a1; return (long)kernel_pathconf((const uint8_t *)p, slen(p), - (int32_t)a2); + (int32_t)a2, + (int64_t *)(uintptr_t)a3); } - /* fpathconf — (fd, name) */ + /* fpathconf — (fd, name, int64_t *value) */ case SYS_FPATHCONF: - return (long)kernel_fpathconf((int32_t)a1, (int32_t)a2); + return (long)kernel_fpathconf((int32_t)a1, (int32_t)a2, + (int64_t *)(uintptr_t)a3); /* realpath — (path, buf, buflen) */ case SYS_REALPATH: { diff --git a/libc/glue/syscall_imports.h b/libc/glue/syscall_imports.h index 78f6b942fd..556de55db5 100644 --- a/libc/glue/syscall_imports.h +++ b/libc/glue/syscall_imports.h @@ -514,10 +514,11 @@ KERNEL_IMPORT(kernel_sysconf) int64_t kernel_sysconf(int32_t name); KERNEL_IMPORT(kernel_pathconf) -int64_t kernel_pathconf(const uint8_t *path_ptr, uint32_t path_len, int32_t name); +int32_t kernel_pathconf(const uint8_t *path_ptr, uint32_t path_len, int32_t name, + int64_t *value_ptr); KERNEL_IMPORT(kernel_fpathconf) -int64_t kernel_fpathconf(int32_t fd, int32_t name); +int32_t kernel_fpathconf(int32_t fd, int32_t name, int64_t *value_ptr); KERNEL_IMPORT(kernel_realpath) int32_t kernel_realpath(const uint8_t *path_ptr, uint32_t path_len, 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/libc/musl-overlay/include/limits.h b/libc/musl-overlay/include/limits.h index 486fd02f01..80bb13212b 100644 --- a/libc/musl-overlay/include/limits.h +++ b/libc/musl-overlay/include/limits.h @@ -40,7 +40,6 @@ #include #define PIPE_BUF 4096 -#define FILESIZEBITS 64 #ifndef NAME_MAX #define NAME_MAX 255 #endif diff --git a/libc/musl-overlay/include/unistd.h b/libc/musl-overlay/include/unistd.h index c95dd594f9..099cade06c 100644 --- a/libc/musl-overlay/include/unistd.h +++ b/libc/musl-overlay/include/unistd.h @@ -523,9 +523,9 @@ pid_t gettid(void); #define _XOPEN_UUCP (-1) /* Feature test macros — supported or partially supported features */ -#define _POSIX_PRIORITIZED_IO _POSIX_VERSION +#define _POSIX_PRIORITIZED_IO (-1) #define _POSIX_PRIORITY_SCHEDULING _POSIX_VERSION -#define _POSIX_SYNCHRONIZED_IO _POSIX_VERSION +#define _POSIX_SYNCHRONIZED_IO (-1) #define _POSIX2_C_DEV (-1) #define _POSIX2_SW_DEV (-1) #define _XOPEN_CRYPT 1 diff --git a/libc/musl-overlay/src/conf/wasm32posix/fpathconf.c b/libc/musl-overlay/src/conf/wasm32posix/fpathconf.c new file mode 100644 index 0000000000..d4768b7f95 --- /dev/null +++ b/libc/musl-overlay/src/conf/wasm32posix/fpathconf.c @@ -0,0 +1,11 @@ +#include +#include +#include "syscall.h" + +long fpathconf(int fd, int name) +{ + int64_t value = -1; + long rc = __syscall(SYS_fpathconf, fd, name, &value); + if (rc < 0) return __syscall_ret(rc); + return (long)value; +} diff --git a/libc/musl-overlay/src/conf/wasm32posix/pathconf.c b/libc/musl-overlay/src/conf/wasm32posix/pathconf.c new file mode 100644 index 0000000000..bb4ee1e959 --- /dev/null +++ b/libc/musl-overlay/src/conf/wasm32posix/pathconf.c @@ -0,0 +1,11 @@ +#include +#include +#include "syscall.h" + +long pathconf(const char *path, int name) +{ + int64_t value = -1; + long rc = __syscall(SYS_pathconf, path, name, &value); + if (rc < 0) return __syscall_ret(rc); + return (long)value; +} diff --git a/libc/musl-overlay/src/network/wasm32posix/if_indextoname.c b/libc/musl-overlay/src/network/wasm32posix/if_indextoname.c index f933623bc8..6293689f6b 100644 --- a/libc/musl-overlay/src/network/wasm32posix/if_indextoname.c +++ b/libc/musl-overlay/src/network/wasm32posix/if_indextoname.c @@ -1,11 +1,23 @@ +#define _GNU_SOURCE #include +#include +#include #include #include +#include "syscall.h" char *if_indextoname(unsigned index, char *name) { - if (index == 1) - return strncpy(name, "lo", IF_NAMESIZE); - errno = ENXIO; - return 0; + struct ifreq ifr = {0}; + int fd, r; + + if ((fd = socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, 0)) < 0) return 0; + ifr.ifr_ifindex = index; + r = ioctl(fd, SIOCGIFNAME, &ifr); + __syscall(SYS_close, fd); + if (r < 0) { + if (errno == ENODEV) errno = ENXIO; + return 0; + } + return strncpy(name, ifr.ifr_name, IF_NAMESIZE); } diff --git a/libc/musl-overlay/src/network/wasm32posix/if_nameindex.c b/libc/musl-overlay/src/network/wasm32posix/if_nameindex.c index 72ca21704e..5ff17a8b9d 100644 --- a/libc/musl-overlay/src/network/wasm32posix/if_nameindex.c +++ b/libc/musl-overlay/src/network/wasm32posix/if_nameindex.c @@ -1,19 +1,49 @@ +#define _GNU_SOURCE #include +#include +#include #include #include +#include "syscall.h" struct if_nameindex *if_nameindex(void) { - /* Return a synthetic loopback interface */ - struct if_nameindex *idx = malloc(2 * sizeof(*idx)); - if (!idx) return 0; - idx[0].if_index = 1; - idx[0].if_name = strdup("lo"); - if (!idx[0].if_name) { - free(idx); - return 0; + struct ifconf ifc = {0}; + struct ifreq *req = 0; + struct if_nameindex *idx = 0; + size_t count = 0; + int fd; + + if ((fd = socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, 0)) < 0) return 0; + + /* Query the host-visible interface list instead of embedding interface + * numbers in libc. The terminating zero entry comes from calloc. */ + if (ioctl(fd, SIOCGIFCONF, &ifc) < 0 || ifc.ifc_len < 0) goto fail; + if (ifc.ifc_len) { + req = malloc(ifc.ifc_len); + if (!req) goto fail; + ifc.ifc_req = req; + if (ioctl(fd, SIOCGIFCONF, &ifc) < 0) goto fail; + count = ifc.ifc_len / sizeof(*req); + } + + idx = calloc(count + 1, sizeof(*idx)); + if (!idx) goto fail; + + for (size_t i = 0; i < count; i++) { + if (ioctl(fd, SIOCGIFINDEX, &req[i]) < 0) goto fail; + idx[i].if_index = req[i].ifr_ifindex; + idx[i].if_name = strdup(req[i].ifr_name); + if (!idx[i].if_name) goto fail; } - idx[1].if_index = 0; - idx[1].if_name = 0; + __syscall(SYS_close, fd); + free(req); return idx; + +fail: + __syscall(SYS_close, fd); + for (size_t i = 0; i < count; i++) free(idx ? idx[i].if_name : 0); + free(idx); + free(req); + return 0; } diff --git a/libc/musl-overlay/src/network/wasm32posix/if_nametoindex.c b/libc/musl-overlay/src/network/wasm32posix/if_nametoindex.c index 4c837e63ab..540da56d7b 100644 --- a/libc/musl-overlay/src/network/wasm32posix/if_nametoindex.c +++ b/libc/musl-overlay/src/network/wasm32posix/if_nametoindex.c @@ -1,8 +1,18 @@ +#define _GNU_SOURCE #include +#include +#include #include +#include "syscall.h" unsigned if_nametoindex(const char *name) { - if (!strcmp(name, "lo")) return 1; - return 0; + struct ifreq ifr = {0}; + int fd, r; + + if ((fd = socket(AF_INET, SOCK_DGRAM|SOCK_CLOEXEC, 0)) < 0) return 0; + strncpy(ifr.ifr_name, name, sizeof ifr.ifr_name - 1); + r = ioctl(fd, SIOCGIFINDEX, &ifr); + __syscall(SYS_close, fd); + return r < 0 ? 0 : ifr.ifr_ifindex; } diff --git a/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c b/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c index 64cac62dc8..8e0301c96c 100644 --- a/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c +++ b/libc/musl-overlay/src/thread/wasm32posix/pthread_cancel.c @@ -30,10 +30,11 @@ * blocked, the call is a no-op — the next cancel point will observe * the flag. * 3. libc/glue/channel_syscall.c::__syscall_cp calls __testcancel() before - * and after the blocking dispatch. __testcancel reads self->cancel - * and, if set (and cancellation is enabled), calls __cancel(), which - * in turn runs pthread_exit(PTHREAD_CANCELED) — unwinding cleanup - * handlers and TSD destructors before the thread terminates. + * the blocking dispatch and __syscall_cp_check() after it. A pending + * cancel terminates the thread only before dispatch or when the host + * interrupted an in-flight cancellation point with EINTR. A syscall + * that already completed keeps its result and leaves cancellation + * pending for the next cancellation point. * * Async cancellation (PTHREAD_CANCEL_ASYNCHRONOUS) is explicitly not * supported: wasm cannot preempt a running thread mid-computation. @@ -77,8 +78,11 @@ void __testcancel(void) * one-function moral equivalent of stock musl's __syscall_cp_asm + * __syscall_cp_c combo: * + * - If the syscall was not interrupted with EINTR, return `r` unchanged. + * In particular, do not discard a successful syscall after its externally + * visible side effects have already happened. * - If the thread has cancellation entirely disabled or no cancel is - * pending, return `r` unchanged (or 0 on the pre-call edge). + * pending, return `r` unchanged. * - If `self->cancel` is set and the state is ENABLE (or async), * terminate the thread via pthread_exit(PTHREAD_CANCELED) — same * path as stock __testcancel. @@ -94,6 +98,7 @@ void __testcancel(void) hidden long __syscall_cp_check(long r) { pthread_t self = __pthread_self(); + if (r != -EINTR) return r; if (!self->cancel) return r; if (self->canceldisable == PTHREAD_CANCEL_DISABLE) return r; if (self->canceldisable == PTHREAD_CANCEL_ENABLE || self->cancelasync) diff --git a/libc/musl-overlay/src/time/timer_create.c b/libc/musl-overlay/src/time/timer_create.c new file mode 100644 index 0000000000..ea8d8a6afa --- /dev/null +++ b/libc/musl-overlay/src/time/timer_create.c @@ -0,0 +1,186 @@ +#include +#include +#include +#include +#include +#include +#include +#include "pthread_impl.h" +#include "atomic.h" + +/* + * Kandelo's timer_create syscall wire is four fixed-width i32 fields on both + * wasm32 and wasm64. Direct SIGEV_SIGNAL/SIGEV_THREAD_ID delivery currently + * carries the sival_int representation. SIGEV_THREAD callback values do not + * cross this wire: the helper copies the full union sigval locally. + */ +struct ksigevent { + int32_t sigev_value; + int32_t sigev_signo; + int32_t sigev_notify; + int32_t sigev_tid; +}; + +_Static_assert(sizeof(struct ksigevent) == 16, + "kernel sigevent wire must remain four i32 fields"); +_Static_assert(offsetof(struct ksigevent, sigev_value) == 0, + "kernel sigevent value offset"); +_Static_assert(offsetof(struct ksigevent, sigev_signo) == 4, + "kernel sigevent signo offset"); +_Static_assert(offsetof(struct ksigevent, sigev_notify) == 8, + "kernel sigevent notify offset"); +_Static_assert(offsetof(struct ksigevent, sigev_tid) == 12, + "kernel sigevent tid offset"); + +struct start_args { + pthread_barrier_t b; + struct sigevent *sev; +}; + +static void dummy_0() +{ +} + +weak_alias(dummy_0, __pthread_tsd_run_dtors); + +static void cleanup_fromsig(void *p) +{ + pthread_t self = __pthread_self(); + __pthread_tsd_run_dtors(); + self->cancel = 0; + self->cancelbuf = 0; + self->canceldisable = 0; + self->cancelasync = 0; + __reset_tls(); + longjmp(p, 1); +} + +static void *start(void *arg) +{ + pthread_t self = __pthread_self(); + struct start_args *args = arg; + jmp_buf jb; + + /* + * Copy callback state before releasing the creating thread. In particular, + * val remains the full native-width union sigval on wasm64. + */ + void (*notify)(union sigval) = args->sev->sigev_notify_function; + union sigval val = args->sev->sigev_value; + + pthread_barrier_wait(&args->b); + if (self->cancel) + return 0; + + for (;;) { + siginfo_t si; + while (sigwaitinfo(SIGTIMER_SET, &si) < 0); + if (si.si_code == SI_TIMER && !setjmp(jb)) { + pthread_cleanup_push(cleanup_fromsig, jb); + notify(val); + pthread_cleanup_pop(1); + } + if (self->timer_id < 0) + break; + } + + __syscall(SYS_timer_delete, self->timer_id & INT_MAX); + return 0; +} + +int timer_create( + clockid_t clk, + struct sigevent *restrict evp, + timer_t *restrict res) +{ + static volatile int init; + pthread_t td; + pthread_attr_t attr; + int r; + struct start_args args; + struct ksigevent ksev, *ksevp = 0; + int timerid; + sigset_t set; + + switch (evp ? evp->sigev_notify : SIGEV_SIGNAL) { + case SIGEV_NONE: + case SIGEV_SIGNAL: + case SIGEV_THREAD_ID: + if (evp) { + /* + * The kernel ABI currently carries sival_int. A direct + * SIGEV_SIGNAL/SIGEV_THREAD_ID sival_ptr wider than 32 bits + * remains unsupported and must stay documented as such. + */ + ksev.sigev_value = evp->sigev_value.sival_int; + ksev.sigev_signo = evp->sigev_notify == SIGEV_NONE + ? 0 + : evp->sigev_signo; + ksev.sigev_notify = evp->sigev_notify; + ksev.sigev_tid = evp->sigev_notify == SIGEV_THREAD_ID + ? evp->sigev_notify_thread_id + : 0; + ksevp = &ksev; + } + if (syscall(SYS_timer_create, clk, ksevp, &timerid) < 0) + return -1; + *res = (void *)(intptr_t)timerid; + return 0; + + case SIGEV_THREAD: + if (!init) { + struct sigaction sa = { .sa_handler = SIG_DFL }; + __libc_sigaction(SIGTIMER, &sa, 0); + a_store(&init, 1); + } + + if (evp->sigev_notify_attributes) + attr = *evp->sigev_notify_attributes; + else + pthread_attr_init(&attr); + pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED); + + pthread_barrier_init(&args.b, 0, 2); + args.sev = evp; + + __block_app_sigs(&set); + __syscall( + SYS_rt_sigprocmask, + SIG_BLOCK, + SIGTIMER_SET, + 0, + _NSIG / 8); + r = pthread_create(&td, &attr, start, &args); + __restore_sigs(&set); + if (r) { + errno = r; + return -1; + } + + /* + * The callback value stays in the helper's local `val`; the kernel + * notification only wakes the exact helper TID. + */ + ksev.sigev_value = 0; + ksev.sigev_signo = SIGTIMER; + ksev.sigev_notify = SIGEV_THREAD_ID; + ksev.sigev_tid = td->tid; + + if (syscall(SYS_timer_create, clk, &ksev, &timerid) < 0) { + timerid = -1; + td->cancel = 1; + } + td->timer_id = timerid; + pthread_barrier_wait(&args.b); + + if (timerid < 0) + return -1; + + *res = (void *)(INTPTR_MIN | (uintptr_t)td >> 1); + return 0; + + default: + errno = EINVAL; + return -1; + } +} diff --git a/packages/registry/curl/build.toml b/packages/registry/curl/build.toml index 342aa52c5f..83f4a44fdf 100644 --- a/packages/registry/curl/build.toml +++ b/packages/registry/curl/build.toml @@ -1,7 +1,11 @@ script_path = "packages/registry/curl/build-curl.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 3 +inputs = [ + "packages/registry/curl/build-curl.sh", + "packages/registry/libcurl/build-libcurl.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 4 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/curl/package.toml b/packages/registry/curl/package.toml index 95d16f3568..77456522aa 100644 --- a/packages/registry/curl/package.toml +++ b/packages/registry/curl/package.toml @@ -2,19 +2,17 @@ kind = "program" name = "curl" version = "8.11.1" kernel_abi = 7 -# curl is a side-output of libcurl's build (libcurl's autoconf produces -# both lib/libcurl.a and src/curl). Keep this manifest aligned with -# libcurl's source so build-curl.sh — which delegates to -# build-libcurl.sh — finds a pre-generated `configure` script. The -# github "archive/refs/tags/" tarball does NOT contain one (autoconf -# runs at release time, not in git); the curl.se release tarball does. +# curl and libcurl come from the same upstream build graph, but they are +# separate resolver packages. This transaction delegates to +# build-libcurl.sh in `curl` package mode and publishes only curl.wasm. Keep +# the source aligned with libcurl's release tarball because GitHub's tag +# archive does not contain the release-generated configure script. # -# build-curl.sh is literally `exec bash build-libcurl.sh`, so curl -# is a strict consumer of libcurl's build. Declaring libcurl folds its -# cache_key into ours, while zlib/openssl must also be direct deps -# because the delegated build script consumes their resolver env vars -# when producing the standalone curl CLI. -depends_on = ["libcurl@8.11.1", "zlib@1.3.1", "openssl@3.3.2"] +# This separate build consumes zlib and OpenSSL resolver paths while linking +# the CLI. It does not consume the libcurl package output: the common source +# version and recipe content are already part of this package's own cache key. +depends_on = ["zlib@1.3.1", "openssl@3.3.2"] +arches = ["wasm32"] [source] url = "https://curl.se/download/curl-8.11.1.tar.xz" @@ -27,6 +25,36 @@ url = "https://github.com/curl/curl/blob/master/COPYING" [build] script_path = "packages/registry/curl/build-curl.sh" +[[host_tools]] +name = "curl" +version_constraint = ">=7.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "wasm-objdump" +version_constraint = ">=1.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + [[outputs]] name = "curl" wasm = "curl.wasm" diff --git a/packages/registry/curl/test/curl.test.ts b/packages/registry/curl/test/curl.test.ts new file mode 100644 index 0000000000..ea9a081ac4 --- /dev/null +++ b/packages/registry/curl/test/curl.test.ts @@ -0,0 +1,64 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdtempSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { pathToFileURL, fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { NodePlatformIO } from "../../../../host/src/platform/node"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const curlBinaryPath = + tryResolveBinary("programs/curl.wasm") ?? + join(__dirname, "../bin/curl.wasm"); +const READY = existsSync(curlBinaryPath); +const scratchDirs: string[] = []; + +afterEach(() => { + for (const scratch of scratchDirs.splice(0)) { + rmSync(scratch, { recursive: true, force: true }); + } +}); + +describe.skipIf(!READY)("curl CLI package", () => { + it("reports the packaged libcurl, OpenSSL, and zlib versions", async () => { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: curlBinaryPath, + argv: ["curl", "--version"], + io: new NodePlatformIO(), + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toContain( + "curl 8.11.1 (wasm32-unknown-none) libcurl/8.11.1 OpenSSL/3.3.2 zlib/1.3.1", + ); + expect(stdout).toContain("Protocols: file ftp ftps http https"); + }, 60_000); + + it("transfers file URL bytes through libcurl", async () => { + const scratch = mkdtempSync(join(tmpdir(), "kandelo-curl-cli-")); + scratchDirs.push(scratch); + const fixture = join(scratch, "fixture.txt"); + writeFileSync(fixture, "kandelo-curl-file-ok\n"); + + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: curlBinaryPath, + argv: [ + "curl", + "--fail", + "--silent", + "--show-error", + pathToFileURL(fixture).href, + ], + io: new NodePlatformIO(), + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toBe("kandelo-curl-file-ok\n"); + }, 60_000); +}); diff --git a/packages/registry/dinit/build-dinit.sh b/packages/registry/dinit/build-dinit.sh index e3e7bf6287..719d87f56c 100755 --- a/packages/registry/dinit/build-dinit.sh +++ b/packages/registry/dinit/build-dinit.sh @@ -110,6 +110,29 @@ fi cd "$SRC_DIR" +# Clang lowers Wasm setjmp/longjmp to an exception transfer. Dasynq's pselect +# backend places the sigsetjmp landing pad inside pull_events(), but marks that +# same function noexcept. A SIGCHLD then reaches std::terminate before the +# internal landing pad can consume the longjmp. This is a Wasm toolchain +# compatibility boundary: keep C++ EH for dinit's real try/catch paths and +# remove only the conflicting noexcept declaration. +PATCH_FILE="$SCRIPT_DIR/patches/0001-wasm-sjlj-pselect-noexcept.patch" +echo "==> Applying dinit Wasm SjLj compatibility patch..." +if git apply --reverse --check "$PATCH_FILE" >/dev/null 2>&1; then + echo " $(basename "$PATCH_FILE") already applied" +elif git apply --check "$PATCH_FILE" >/dev/null 2>&1; then + git apply "$PATCH_FILE" +else + echo "ERROR: $(basename "$PATCH_FILE") does not apply cleanly" >&2 + exit 1 +fi + +HOST_CXX="${CXX_FOR_BUILD:-c++}" +if [ -n "${NIX_CC_FOR_BUILD:-}" ] \ + && [ -x "$NIX_CC_FOR_BUILD/bin/c++" ]; then + HOST_CXX="$NIX_CC_FOR_BUILD/bin/c++" +fi + # --- Configure --- # dinit's build is driven by mconfig (a make-included config file). We # generate one by hand for the cross-compile rather than running @@ -123,20 +146,23 @@ cat > mconfig < class pselect_events : public signal_events + // + // do_wait - if false, returns immediately if no events are + // pending. +- void pull_events(bool do_wait) noexcept ++ void pull_events(bool do_wait) + { + struct timespec ts; + struct timespec *wait_ts = nullptr; diff --git a/packages/registry/dinit/test/dinit-scripted-service.test.ts b/packages/registry/dinit/test/dinit-scripted-service.test.ts new file mode 100644 index 0000000000..253fab36e7 --- /dev/null +++ b/packages/registry/dinit/test/dinit-scripted-service.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { ABI_VERSION } from "../../../../host/src/generated/abi"; +import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import type { HostDiagnostic } from "../../../../host/src/host-diagnostic"; +import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; +import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; +import { + ensureDirRecursive, + writeVfsBinary, + writeVfsFile, +} from "../../../../host/src/vfs/image-helpers"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { addDinitInit } from "../../../../images/vfs/scripts/dinit-image-helpers"; + +const dinitBinary = tryResolveBinary("programs/dinit/dinit.wasm"); +const dinitctlBinary = tryResolveBinary("programs/dinit/dinitctl.wasm"); +const coreutilsBinary = tryResolveBinary("programs/coreutils.wasm"); +const hasArtifacts = !!dinitBinary && !!dinitctlBinary && !!coreutilsBinary; + +async function createScriptedServiceImage( + createdBy: string, + malformedRestart = false, +): Promise { + const maxBytes = 32 * 1024 * 1024; + const sab = new SharedArrayBuffer(maxBytes, { maxByteLength: maxBytes }); + const fs = MemoryFileSystem.create(sab, maxBytes); + for (const dir of ["/bin", "/var", "/home", "/root", "/srv"]) { + ensureDirRecursive(fs, dir); + } + writeVfsBinary( + fs, + "/bin/true", + new Uint8Array(readFileSync(coreutilsBinary!)), + ); + addDinitInit(fs, [{ + name: "one-shot", + type: "scripted", + command: "/bin/true", + restart: false, + }]); + if (malformedRestart) { + writeVfsFile( + fs, + "/etc/dinit.d/one-shot", + "type = scripted\ncommand = /bin/true\nrestart = banana\n", + ); + } + return fs.saveImage({ + metadata: { + version: 1, + kernelAbi: ABI_VERSION, + createdBy, + }, + }); +} + +describe.skipIf(!hasArtifacts)("dinit supervisor", () => { + it( + "completes a scripted service without aborting", + { timeout: 30_000 }, + async () => { + const dinitBytes = readFileSync(dinitBinary!); + expect(dinitBytes.includes(Buffer.from("wpk_fork_state"))).toBe(true); + + const image = await createScriptedServiceImage( + "dinit scripted-service integration test", + ); + + let stdout = ""; + let stderr = ""; + const diagnostics: HostDiagnostic[] = []; + const events: Array<{ + kind: "spawn" | "exec" | "exit"; + pid: number; + ppid?: number; + exitStatus?: number; + }> = []; + const stdoutDecoder = new TextDecoder(); + const stderrDecoder = new TextDecoder(); + const host = new NodeKernelHost({ + rootfsImage: image, + onStdout: (_pid, data) => { + stdout += stdoutDecoder.decode(data, { stream: true }); + }, + onStderr: (_pid, data) => { + stderr += stderrDecoder.decode(data, { stream: true }); + }, + onHostDiagnostic: (diagnostic) => diagnostics.push(diagnostic), + onProcessEvent: (event) => events.push(event), + }); + + await host.init(); + let dinitPid = -1; + let dinitExit: Promise | undefined; + try { + const program = dinitBytes.buffer.slice( + dinitBytes.byteOffset, + dinitBytes.byteOffset + dinitBytes.byteLength, + ) as ArrayBuffer; + dinitExit = host.spawn( + program, + ["/sbin/dinit", "--container", "-p", "/tmp/dinitctl", "boot"], + { + cwd: "/", + stdin: new Uint8Array(), + onStarted: (pid) => { + dinitPid = pid; + }, + }, + ); + + await expect.poll( + () => stdout, + { timeout: 10_000, interval: 50 }, + ).toContain("[ OK ] one-shot"); + + // A successful scripted service means dinit forked and exec'd the + // helper, reaped its zero exit status, and stayed alive as PID 1. + // Leaving dasynq's pselect pull_events() noexcept makes the Wasm SjLj + // transfer reach std::terminate while handling SIGCHLD instead. + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ kind: "spawn", ppid: dinitPid }), + expect.objectContaining({ kind: "exec" }), + expect.objectContaining({ kind: "exit", exitStatus: 0 }), + ])); + expect((await host.enumProcs()).some(({ pid }) => pid === dinitPid)) + .toBe(true); + expect(stderr).not.toContain("libc++abi: terminating"); + expect(diagnostics).toEqual([]); + } finally { + await host.destroy().catch(() => {}); + void dinitExit?.catch(() => {}); + } + }, + ); + + it( + "reports malformed service settings through dinit's C++ catch path", + { timeout: 20_000 }, + async () => { + const image = await createScriptedServiceImage( + "dinit malformed-service integration test", + true, + ); + const result = await runCentralizedProgram({ + programPath: dinitBinary!, + argv: ["/sbin/dinit", "--container", "-p", "/tmp/dinitctl", "boot"], + rootfsImage: image, + timeout: 10_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stdout).toContain("Error in service description for 'one-shot'"); + expect(result.stdout).toContain("restart must be one of"); + expect(result.stderr).toBe(""); + expect(result.hostDiagnostics).toEqual([]); + }, + ); +}); diff --git a/packages/registry/dinit/test/dinitctl.test.ts b/packages/registry/dinit/test/dinitctl.test.ts new file mode 100644 index 0000000000..f920e8a44d --- /dev/null +++ b/packages/registry/dinit/test/dinitctl.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; + +const dinitctlBinary = tryResolveBinary("programs/dinit/dinitctl.wasm"); + +describe.skipIf(!dinitctlBinary)("dinitctl", () => { + it("reports a missing control socket without trapping in the host", async () => { + const socketPath = "/tmp/kandelo-dinitctl-missing.sock"; + const result = await runCentralizedProgram({ + programPath: dinitctlBinary!, + argv: ["dinitctl", "-p", socketPath, "list"], + timeout: 10_000, + }); + + expect(result.exitCode).toBe(1); + expect(result.stderr).toContain(`connecting to socket: ${socketPath}`); + expect(result.hostDiagnostics).toEqual([ + expect.objectContaining({ source: "process exit", status: 1 }), + ]); + expect( + result.hostDiagnostics.map(({ message }) => message).join("\n"), + ).not.toContain("WebAssembly.Exception"); + }); +}); diff --git a/packages/registry/erlang-vfs/build.toml b/packages/registry/erlang-vfs/build.toml index 071017f82f..2971c033f9 100644 --- a/packages/registry/erlang-vfs/build.toml +++ b/packages/registry/erlang-vfs/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/erlang-vfs/build-erlang-vfs.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 2 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/icu/build-icu.sh b/packages/registry/icu/build-icu.sh new file mode 100755 index 0000000000..79ea14e6bb --- /dev/null +++ b/packages/registry/icu/build-icu.sh @@ -0,0 +1,235 @@ +#!/usr/bin/env bash +# +# Build ICU4C (libicuuc.a, libicui18n.a, libicudata.a stub + icu.dat) for +# wasm32-posix-kernel. +# +# ICU requires a TWO-STAGE build: +# +# Stage 1 (HOST): build ICU natively to produce the data-generation tools +# (genrb, pkgdata, icupkg, genccode, …) and the ICU common +# data. These run on the build machine. +# Stage 2 (CROSS): configure ICU for wasm32 with --with-cross-build pointing +# at the stage-1 build dir. The cross build reuses the host +# tools and host-generated data; it only compiles the C++ +# sources into wasm32 static libraries. +# +# Data is built in `archive` packaging mode, which emits the ICU common data as +# a standalone `icudtl.dat` file (NOT linked into libicudata.a — that +# becomes a stub). We install that file as `share/icu.dat`; PHP's intl side +# module loads it at runtime via udata_setCommonData() (the name `icu.dat` is +# deliberate and is NOT ICU's default-searched name). See +# packages/registry/php/build-php.sh for the intl side. +# +# Honors the dep-resolver build-script contract (see docs/package-management.md). +# When invoked via `cargo xtask build-deps resolve icu`, the resolver sets: +# WASM_POSIX_DEP_OUT_DIR # where to install +# WASM_POSIX_DEP_VERSION # upstream version (e.g. "74.2") +# WASM_POSIX_DEP_SOURCE_URL # tarball URL +# WASM_POSIX_DEP_SOURCE_SHA256 # expected sha256 of the tarball +# WASM_POSIX_DEP_LIBCXX_DIR # resolved libcxx prefix (direct dep) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +if ! command -v wasm32posix-cc &>/dev/null; then + echo "ERROR: wasm32posix-cc not found after sourcing sdk/activate.sh." >&2 + exit 1 +fi + +# --- Inputs from resolver, with ad-hoc fallbacks --- +ICU_VERSION="${WASM_POSIX_DEP_VERSION:-${ICU_VERSION:-74.2}}" +ICU_VER_UNDERSCORE="${ICU_VERSION//./_}" # 74.2 -> 74_2 +ICU_MAJOR="${ICU_VERSION%%.*}" # 74.2 -> 74 +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/icu-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/unicode-org/icu/releases/download/release-${ICU_MAJOR}-${ICU_VERSION#*.}/icu4c-${ICU_VER_UNDERSCORE}-src.tgz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-68db082212a96d6f53e35d60f47d38b962e9f9d207a74cfac78029ae8ff5e08c}" + +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: ICU currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-icu.XXXXXX")" +cleanup() { + status=$? + trap - EXIT + if [ "${WASM_POSIX_KEEP_BUILD_DIR:-0}" = "1" ]; then + echo "==> Preserving ICU build directory: $WORK_DIR" >&2 + else + rm -rf "$WORK_DIR" + fi + exit "$status" +} +trap cleanup EXIT + +SRC_ROOT="$WORK_DIR/source" # contains icu/ (with source/) +ICU_SRC="$SRC_ROOT/icu/source" +HOST_BUILD="$WORK_DIR/host-build" # stage-1 native build (out-of-tree) + +# --- Resolve libcxx (ICU is C++), symlink into sysroot (mariadb pattern) --- +HOST_TARGET="$(rustc -vV | awk '/^host/ {print $2}')" +resolve_dep() { + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TARGET" --quiet -- build-deps --arch "$TARGET_ARCH" resolve "$1") +} +LIBCXX_PREFIX="${WASM_POSIX_DEP_LIBCXX_DIR:-}" +if [ -z "$LIBCXX_PREFIX" ]; then + echo "==> Resolving libcxx via cargo xtask build-deps..." + LIBCXX_PREFIX="$(resolve_dep libcxx)" +fi +[ -f "$LIBCXX_PREFIX/lib/libc++.a" ] || { echo "ERROR: libcxx resolve missing libc++.a at $LIBCXX_PREFIX" >&2; exit 1; } +[ -f "$LIBCXX_PREFIX/lib/libc++abi.a" ] || { echo "ERROR: libcxx resolve missing libc++abi.a at $LIBCXX_PREFIX" >&2; exit 1; } +[ -d "$LIBCXX_PREFIX/include/c++/v1" ] || { echo "ERROR: libcxx resolve missing include/c++/v1 at $LIBCXX_PREFIX" >&2; exit 1; } + +echo "==> Linking libcxx into sysroot ($LIBCXX_PREFIX)..." +mkdir -p "$SYSROOT/lib" "$SYSROOT/include/c++" +ln -sf "$LIBCXX_PREFIX/lib/libc++.a" "$SYSROOT/lib/libc++.a" +ln -sf "$LIBCXX_PREFIX/lib/libc++abi.a" "$SYSROOT/lib/libc++abi.a" +rm -rf "$SYSROOT/include/c++/v1" +ln -sfn "$LIBCXX_PREFIX/include/c++/v1" "$SYSROOT/include/c++/v1" + +# --- Fetch + verify source --- +if [ ! -d "$ICU_SRC" ]; then + echo "==> Downloading ICU $ICU_VERSION..." + TARBALL="$WORK_DIR/icu4c-${ICU_VER_UNDERSCORE}-src.tgz" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - + mkdir -p "$SRC_ROOT" + tar xzf "$TARBALL" -C "$SRC_ROOT" # extracts icu/ + rm "$TARBALL" +fi + +NPROC="$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +run_logged() { + local label="$1" + shift + local log="$WORK_DIR/$label.log" + if "$@" >"$log" 2>&1; then + tail -20 "$log" + return 0 + fi + echo "ERROR: $label failed; final log follows:" >&2 + tail -200 "$log" >&2 + return 1 +} + +# ============================================================ +# Stage 1 — HOST build (native tools + data) +# ============================================================ +# Uses the host compiler (clang/clang++ from the dev shell), NOT the wasm +# wrappers. sdk/activate.sh only prepends SDK bin to PATH; it does not export +# CC/CXX, so an explicit host CC/CXX keeps this stage native. +# +# On Linux, statically fold the GNU C++/GCC runtime into the data tools: the Nix +# CI runner has no libstdc++.so.6 on its loader path, so a dynamically linked +# icupkg/pkgdata (run here by Stage 2's make) aborts at exec with "cannot open +# shared object file". macOS clang links a self-contained libc++ and rejects the +# flags. LDFLAGS set here is honored: runConfigureICU re-exports it to configure. +case "$(uname -s)" in + Linux) HOST_LDFLAGS="-static-libstdc++ -static-libgcc" ;; + *) HOST_LDFLAGS="" ;; +esac +if [ ! -x "$HOST_BUILD/bin/icupkg" ] && [ ! -x "$HOST_BUILD/bin/genccode" ]; then + echo "==> Stage 1: building ICU natively for host tools + data..." + rm -rf "$HOST_BUILD" + mkdir -p "$HOST_BUILD" + ( cd "$HOST_BUILD" + CC="${HOST_CC:-clang}" CXX="${HOST_CXX:-clang++}" \ + LDFLAGS="$HOST_LDFLAGS" \ + "$ICU_SRC/runConfigureICU" MacOSX \ + --enable-static --disable-shared \ + --disable-samples --disable-tests --disable-extras + run_logged host-make make -j"$NPROC" + ) +else + echo "==> Stage 1: reusing existing host build at $HOST_BUILD" +fi + +# ============================================================ +# Stage 2 — CROSS build (wasm32 static libs) +# ============================================================ +echo "==> Stage 2: cross-configuring ICU for wasm32..." +# In-tree cross build (wasm32posix-configure runs ./configure in CWD). +# Scrub any prior cross-build state in the source tree. +cd "$ICU_SRC" +make distclean 2>/dev/null || true + +# ICU maps the configure host triple to a config/mh- makefile +# fragment. Our SDK forces --host=wasm32-unknown-none, whose OS component +# ("none") ICU does not recognize, so it selects the stock config/mh-unknown — +# a stub that hard-errors "configure could not detect your platform" and aborts +# `make`. ICU's own remedy (printed in that error) is to supply mh-unknown from +# a known platform. We use mh-linux: this is a --disable-shared --enable-static +# build, so mh-linux's Linux shared-library rules are never exercised; only its +# generic compile rules apply, driven by our wasm CC/CXX. Idempotent overwrite, +# re-applied every run because a fresh source extraction resets it. +cp "$ICU_SRC/config/mh-linux" "$ICU_SRC/config/mh-unknown" + +# C++ flags: ICU 74 needs C++17. libc++ headers come from the sysroot symlink. +# LDFLAGS carries -lc++ -lc++abi so configure's C++ link probes resolve. +# -fPIC: ICU's static libs are absorbed into intl.so, a wasm SIDE MODULE linked +# with `-shared --experimental-pic`. wasm-ld requires EVERY input object to be +# position-independent; a non-PIC ICU object triggers "R_WASM_MEMORY_ADDR_SLEB +# cannot be used against symbol ...; recompile with -fPIC" at the intl.so link. +CXXFLAGS="-O2 -std=c++17 -fPIC" \ +CFLAGS="-O2 -fPIC" \ +LDFLAGS="-lc++ -lc++abi" \ +wasm32posix-configure \ + --with-cross-build="$HOST_BUILD" \ + --enable-static --disable-shared \ + --disable-tools --disable-tests --disable-samples --disable-extras \ + --disable-layoutex \ + --with-data-packaging=archive \ + --prefix="$INSTALL_DIR" + +echo "==> Stage 2: building wasm32 libraries..." +# ICU bakes ICUDATA_DIR into common/putil.ao when data packaging is `common`. +# The resolver install prefix is a random `.tmp-` directory, so leaving +# the generated default in place makes libicuuc.a differ on every clean build. +# The PHP extension stages icu.dat at this stable guest directory and calls +# udata_setCommonData() explicitly; use the same path for the fallback string. +run_logged wasm-make make -j"$NPROC" ICUDATA_DIR=/usr/lib/php + +echo "==> Installing to $INSTALL_DIR..." +rm -rf "$INSTALL_DIR" +run_logged wasm-install make install + +# ICU's generated pkg-config metadata records --prefix verbatim. Resolver +# builds install into a temporary directory and later rewrite that to the +# producer's canonical cache path, which still breaks after an archive is +# fetched or moved elsewhere. Keep the metadata package-relative instead: each +# file lives in lib/pkgconfig, so pcfiledir/../.. is the current ICU prefix in +# every cache, extracted archive, or local overlay. +for pc in icu-uc.pc icu-i18n.pc icu-io.pc; do + pc_path="$INSTALL_DIR/lib/pkgconfig/$pc" + [ -f "$pc_path" ] || { echo "ERROR: missing ICU pkg-config output $pc_path" >&2; exit 1; } + sed 's|^prefix = .*|prefix = ${pcfiledir}/../..|' "$pc_path" > "$pc_path.tmp" + mv "$pc_path.tmp" "$pc_path" +done + +# --- Stage the common data as icu.dat (see header) --- +DAT_SRC="$(find "$ICU_SRC/data" "$HOST_BUILD/data" -name "icudt${ICU_MAJOR}l.dat" 2>/dev/null | head -1 || true)" +if [ -z "$DAT_SRC" ]; then + echo "ERROR: could not locate icudt${ICU_MAJOR}l.dat after build" >&2 + exit 1 +fi +mkdir -p "$INSTALL_DIR/share" +cp "$DAT_SRC" "$INSTALL_DIR/share/icu.dat" +echo "==> staged $(basename "$DAT_SRC") -> $INSTALL_DIR/share/icu.dat ($(wc -c < "$INSTALL_DIR/share/icu.dat") bytes)" + +# --- Sanity: the static libs we promise (icuio included: PHP's PHP_SETUP_ICU +# requires the icu-io pkg-config module, so intl won't configure without it) --- +for lib in libicuuc.a libicui18n.a libicuio.a libicudata.a; do + [ -f "$INSTALL_DIR/lib/$lib" ] || { echo "ERROR: missing $INSTALL_DIR/lib/$lib" >&2; exit 1; } +done +echo "==> ICU build complete." +ls -lh "$INSTALL_DIR/lib/"*.a "$INSTALL_DIR/share/icu.dat" diff --git a/packages/registry/icu/build.toml b/packages/registry/icu/build.toml new file mode 100644 index 0000000000..c2d176224a --- /dev/null +++ b/packages/registry/icu/build.toml @@ -0,0 +1,16 @@ +script_path = "packages/registry/icu/build-icu.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +# Revision 3: ICU static libs rebuilt with -fPIC so they can be absorbed into +# intl.so (a wasm side module built with -shared --experimental-pic, which +# requires all inputs to be position-independent). +# Revision 4: Stage-1 host tools statically link the GNU C++/GCC runtime on +# Linux so icupkg/pkgdata do not need libstdc++.so.6 on the Nix CI runner. +# Revision 5: compile ICU's default data directory as the stable guest path +# /usr/lib/php instead of the resolver's random temporary install prefix. +# Revision 6: make the three pkg-config prefixes relative to pcfiledir so a +# fetched or moved archive resolves its own headers and libraries. +revision = 6 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/icu/package.toml b/packages/registry/icu/package.toml new file mode 100644 index 0000000000..f49bdd7e77 --- /dev/null +++ b/packages/registry/icu/package.toml @@ -0,0 +1,86 @@ +kind = "library" +name = "icu" +version = "74.2" +kernel_abi = 7 +# ICU4C is C++; it links libc++/libc++abi from the libcxx package. +depends_on = ["libcxx@21.1.7"] +# wasm32 only for now — the sole consumer is PHP's intl side module, +# which is a wasm32 build. Add wasm64 only if a wasm64 consumer appears. +arches = ["wasm32"] + +# ICU4C for wasm32, built as static libraries: +# lib/libicuuc.a (common) +# lib/libicui18n.a (internationalization) +# lib/libicuio.a (ICU stream I/O used by PHP's configure contract) +# lib/libicudata.a (stubdata — real data lives in the .dat, see below) +# include/unicode/*.h +# lib/pkgconfig/icu-{uc,i18n,io}.pc +# share/icu.dat (common data archive; renamed from icudt74l.dat per +# the intl side-module design — loaded at runtime via +# udata_setCommonData, NOT ICU's default name search) +# +# ICU requires a two-stage build: a HOST build (to generate the data and the +# genrb/pkgdata/icupkg tools) followed by a wasm32 cross build pointed at the +# host build via --with-cross-build. Data is built in `archive` packaging mode +# so the common data is emitted as a standalone icudt74l.dat instead of being +# linked into libicudata.a; we stage that file as `icu.dat`. + +[source] +url = "https://github.com/unicode-org/icu/releases/download/release-74-2/icu4c-74_2-src.tgz" +sha256 = "68db082212a96d6f53e35d60f47d38b962e9f9d207a74cfac78029ae8ff5e08c" + +[license] +spdx = "ICU" +url = "https://github.com/unicode-org/icu/blob/main/icu4c/LICENSE" + +[build] +script_path = "packages/registry/icu/build-icu.sh" + +# Stage 1 compiles native ICU data tools; Stage 2 invokes GNU make around the +# SDK's wasm wrappers. Source fetching and direct (non-resolver) dependency +# fallback also need the declared network/Rust tools. Keep these probes aligned +# with the repository dev shell instead of accepting ambient host state. +[[host_tools]] +name = "clang" +version_constraint = ">=21.0" +probe = { args = ["--version"], version_regex = "clang version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "clang++" +version_constraint = ">=21.0" +probe = { args = ["--version"], version_regex = "clang version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "rustc" +version_constraint = ">=1.85" +probe = { args = ["--version"], version_regex = "rustc (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "cargo" +version_constraint = ">=1.85" +probe = { args = ["--version"], version_regex = "cargo (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[outputs] +libs = ["lib/libicuuc.a", "lib/libicui18n.a", "lib/libicuio.a", "lib/libicudata.a"] +headers = ["include/unicode"] +pkgconfig = ["lib/pkgconfig/icu-uc.pc", "lib/pkgconfig/icu-i18n.pc", "lib/pkgconfig/icu-io.pc"] +# ICU common data is a first-class declared runtime file, staged from +# icudtl.dat. Consumers read it from the resolved dependency directory at +# $WASM_POSIX_DEP_ICU_DIR/share/icu.dat and load it with udata_setCommonData(). +files = ["share/icu.dat"] diff --git a/packages/registry/kandelo-sdk/build.toml b/packages/registry/kandelo-sdk/build.toml index 18fb0b38f2..a34738eeaa 100644 --- a/packages/registry/kandelo-sdk/build.toml +++ b/packages/registry/kandelo-sdk/build.toml @@ -26,7 +26,7 @@ inputs = [ ] repo_url = "https://github.com/Automattic/kandelo.git" commit = "UNPUBLISHED" -revision = 1 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/kernel/build-kernel.sh b/packages/registry/kernel/build-kernel.sh index 15cf94eef7..f1f7c0be0f 100755 --- a/packages/registry/kernel/build-kernel.sh +++ b/packages/registry/kernel/build-kernel.sh @@ -29,14 +29,17 @@ wasm_require_exports "$OUT" \ __abi_version \ kernel_alloc_scratch \ kernel_create_process \ + kernel_create_process_with_stdio \ kernel_get_parent_pid \ + kernel_get_process_state \ kernel_handle_channel \ + kernel_has_sa_nocldstop \ kernel_host_adapter_manifest_len \ kernel_host_adapter_manifest_ptr \ kernel_mark_process_signaled \ kernel_reap_exited_child \ kernel_remove_process \ - kernel_wait4_poll + kernel_wait_child_poll mkdir -p "$REPO_ROOT/local-binaries" cp "$OUT" "$REPO_ROOT/local-binaries/kernel.wasm" diff --git a/packages/registry/lamp/build.toml b/packages/registry/lamp/build.toml index cfa8902050..3153c80868 100644 --- a/packages/registry/lamp/build.toml +++ b/packages/registry/lamp/build.toml @@ -8,6 +8,7 @@ inputs = [ "images/vfs/scripts/build-lamp-vfs-image.sh", "images/vfs/scripts/build-lamp-vfs-image.ts", "images/vfs/scripts/dinit-image-helpers.ts", + "images/rootfs/etc/services", "images/vfs/scripts/kandelo-demo-config.ts", "images/vfs/scripts/kandelo-demo-guides.ts", "images/vfs/scripts/opcache-prewarm.ts", @@ -31,7 +32,7 @@ inputs = [ ] repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 7 +revision = 8 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/lamp/package.toml b/packages/registry/lamp/package.toml index d898b8a55c..b7ad6ee9ce 100644 --- a/packages/registry/lamp/package.toml +++ b/packages/registry/lamp/package.toml @@ -14,7 +14,7 @@ depends_on = [ "shell@0.1.0", "mariadb@10.5.28", "nginx@1.24.0", - "php@8.3.2", + "php@8.3.15", "dinit@0.19.4", "msmtpd@1.8.32", ] diff --git a/packages/registry/less/build-less.sh b/packages/registry/less/build-less.sh index 8a54a9beaf..1518072b3f 100755 --- a/packages/registry/less/build-less.sh +++ b/packages/registry/less/build-less.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash set -euo pipefail -# Build less 661 for wasm32-posix-kernel. +# Build less for wasm32-posix-kernel. # # Uses the SDK's wasm32posix-configure wrapper for cross-compilation. # Output: packages/registry/less/bin/less.wasm @@ -11,7 +11,7 @@ set -euo pipefail # returns "not found" — less then falls back to hardcoded ANSI sequences. # We also provide a minimal termcap.h header. -LESS_VERSION="${LESS_VERSION:-661}" +LESS_VERSION="${LESS_VERSION:-668}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" SRC_DIR="$SCRIPT_DIR/less-src" @@ -47,8 +47,28 @@ fi if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading less $LESS_VERSION..." TARBALL="less-${LESS_VERSION}.tar.gz" - URL="https://www.greenwoodsoftware.com/less/${TARBALL}" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$URL" -o "/tmp/$TARBALL" + DOWNLOAD_URLS=( + "https://www.greenwoodsoftware.com/less/${TARBALL}" + "https://ftp.gnu.org/gnu/less/${TARBALL}" + ) + for URL in "${DOWNLOAD_URLS[@]}"; do + if curl \ + --connect-timeout 20 \ + --retry 3 \ + --retry-delay 5 \ + --retry-max-time 120 \ + --retry-all-errors \ + -fsSL "$URL" \ + -o "/tmp/$TARBALL" + then + break + fi + rm -f "/tmp/$TARBALL" + done + if [ ! -f "/tmp/$TARBALL" ]; then + echo "ERROR: failed to download $TARBALL from all configured mirrors" >&2 + exit 1 + fi mkdir -p "$SRC_DIR" tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 rm "/tmp/$TARBALL" diff --git a/packages/registry/libcurl/build-libcurl.sh b/packages/registry/libcurl/build-libcurl.sh index a71f6dcee8..5302a0d7b5 100755 --- a/packages/registry/libcurl/build-libcurl.sh +++ b/packages/registry/libcurl/build-libcurl.sh @@ -1,301 +1,469 @@ #!/usr/bin/env bash # -# Build curl for wasm32-posix-kernel. +# Build libcurl or the curl CLI for wasm32 Kandelo. # -# Two modes: +# The resolver invokes this recipe for two package names: +# libcurl -> lib/libcurl.a, include/curl/, lib/pkgconfig/libcurl.pc +# curl -> curl.wasm # -# Resolver mode (`cargo xtask build-deps resolve libcurl`): -# Env vars WASM_POSIX_DEP_OUT_DIR / _ZLIB_DIR / _OPENSSL_DIR are -# set. Builds libcurl + headers + pkgconfig, `make install` into -# $WASM_POSIX_DEP_OUT_DIR, then drops bin/ and share/ — the CLI -# is a consumer artifact, not a library output. -# -# Legacy mode (`bash build-libcurl.sh`): -# No resolver env. Builds the curl CLI in the source tree and -# registers curl.wasm via install-local-binary. Relies on zlib -# and openssl artifacts existing in $REPO_ROOT/sysroot/, the way -# `run.sh build_libcurl` wires them up. +# Resolver builds use a disposable source tree and install only the outputs +# declared by that package. A direct invocation retains the historical +# curl-src/ tree because run.sh consumes its libcurl archive and headers. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/curl-src" -# --- Inputs from resolver, with legacy fallbacks --- +# Use the SDK from this checkout rather than whichever npm link happens to be +# globally active. +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + CURL_VERSION="${WASM_POSIX_DEP_VERSION:-${CURL_VERSION:-8.11.1}}" SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://curl.se/download/curl-${CURL_VERSION}.tar.xz}" -SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-c7ca7db48b0909743eaef34250da02c19bc61d4f1dcedd6603f109409536ab56}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +PACKAGE_NAME="${WASM_POSIX_DEP_NAME:-legacy}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: curl currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi +CC="$(command -v wasm32posix-cc || true)" +AR="$(command -v wasm32posix-ar || true)" +NM="$(command -v wasm32posix-nm || true)" +for tool in "$CC" "$AR" "$NM"; do + if [ -z "$tool" ] || ! command -v "$tool" >/dev/null 2>&1; then + echo "ERROR: required libcurl SDK tool is unavailable: ${tool:-}" >&2 + exit 1 + fi +done +for tool in curl make tar shasum wasm-objdump; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "ERROR: required libcurl host tool is unavailable: $tool" >&2 + exit 1 + fi +done + +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-libcurl.XXXXXX")" +cleanup() { + if [ "${WASM_POSIX_KEEP_BUILD_DIR:-0}" = "1" ]; then + echo "==> Preserving curl build directory: $WORK_DIR" >&2 + else + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT -# Resolver vs legacy is decided by whether WASM_POSIX_DEP_OUT_DIR is -# set. In resolver mode INSTALL_DIR is the cache temp dir; in legacy -# mode it's the in-tree bin/ for curl.wasm. RESOLVER_MODE=0 if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then RESOLVER_MODE=1 + SRC_DIR="$WORK_DIR/source" INSTALL_DIR="$WASM_POSIX_DEP_OUT_DIR" + STAGE_DIR="$WORK_DIR/stage" else + SRC_DIR="$SCRIPT_DIR/curl-src" INSTALL_DIR="$SCRIPT_DIR/bin" + STAGE_DIR="" fi -if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 - exit 1 -fi - -# Use the SDK's sysroot resolver: explicit env var wins, else the -# in-tree $REPO_ROOT/sysroot. Keeps resolver invocations from -# neighbouring worktrees viable (WASM_POSIX_SYSROOT=/sysroot). SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" if [ ! -f "$SYSROOT/lib/libc.a" ]; then - echo "ERROR: sysroot not found at $SYSROOT. Run: bash build.sh && bash scripts/build-musl.sh" >&2 + echo "ERROR: sysroot not found at $SYSROOT; build musl first" >&2 exit 1 fi export WASM_POSIX_SYSROOT="$SYSROOT" -# --- Locate zlib + openssl --- -# Resolver mode surfaces the per-dep install dirs via the contract -# env vars. Legacy mode falls back to the sysroot artifacts that -# `run.sh build_libcurl` installs alongside the build. ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:-}" -if [ -z "$ZLIB_PREFIX" ] && [ -f "$SYSROOT/lib/libz.a" ]; then - ZLIB_PREFIX="$SYSROOT" -fi - OPENSSL_PREFIX="${WASM_POSIX_DEP_OPENSSL_DIR:-}" -if [ -z "$OPENSSL_PREFIX" ] \ - && [ -f "$SYSROOT/lib/libssl.a" ] \ - && [ -f "$SYSROOT/lib/libcrypto.a" ]; then - OPENSSL_PREFIX="$SYSROOT" +if [ "$RESOLVER_MODE" = "0" ]; then + [ -n "$ZLIB_PREFIX" ] || ZLIB_PREFIX="$SYSROOT" + [ -n "$OPENSSL_PREFIX" ] || OPENSSL_PREFIX="$SYSROOT" fi - -# --- Fetch + verify source --- -if [ ! -d "$SRC_DIR" ]; then - echo "==> Downloading curl $CURL_VERSION..." - TARBALL="/tmp/curl-${CURL_VERSION}.tar.xz" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" - if [ -n "$SOURCE_SHA256" ]; then - echo "==> Verifying source sha256..." - echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - - else - echo "==> (no SOURCE_SHA256 declared; skipping verification)" - fi - mkdir -p "$SRC_DIR" - tar xJf "$TARBALL" -C "$SRC_DIR" --strip-components=1 - rm "$TARBALL" - echo "==> Source extracted to $SRC_DIR" +if [ ! -f "$ZLIB_PREFIX/lib/libz.a" ]; then + echo "ERROR: declared zlib dependency is missing at $ZLIB_PREFIX" >&2 + exit 1 +fi +if [ ! -f "$OPENSSL_PREFIX/lib/libssl.a" ] \ + || [ ! -f "$OPENSSL_PREFIX/lib/libcrypto.a" ]; then + echo "ERROR: declared OpenSSL dependency is incomplete at $OPENSSL_PREFIX" >&2 + exit 1 fi +echo "==> Downloading curl $CURL_VERSION..." +TARBALL="$WORK_DIR/curl.tar.xz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +rm -rf "$SRC_DIR" +mkdir -p "$SRC_DIR" +tar xJf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + cd "$SRC_DIR" -# In resolver mode INSTALL_DIR varies per cache key (autoconf bakes -# it into the Makefile) — always reconfigure from scratch. Legacy -# mode keeps the "reconfigure only if no Makefile" shortcut that -# existing callers relied on. -if [ "$RESOLVER_MODE" = "1" ]; then - make distclean 2>/dev/null || true - rm -f Makefile - rm -rf "$INSTALL_DIR" - mkdir -p "$INSTALL_DIR" -fi +# Configure probes must reflect the wasm target, not the build host. +export ac_cv_func_closesocket=no +export ac_cv_func_CloseSocket=no +export ac_cv_func_fcntl=yes +export ac_cv_func_ioctl=yes +export ac_cv_func_fchmod=yes +export ac_cv_func_fnmatch=yes +export ac_cv_func_basename=yes +export ac_cv_func_connect=yes +export ac_cv_func_gethostname=yes +export ac_cv_func_gettimeofday=yes +export ac_cv_func_poll=yes +export ac_cv_func_select=yes +export ac_cv_func_socket=yes +export ac_cv_func_socketpair=yes +export ac_cv_func_getifaddrs=no +export ac_cv_func_if_nametoindex=no +export ac_cv_func_freeifaddrs=no +export ac_cv_func_getpwuid=yes +export ac_cv_func_getpwuid_r=no +export ac_cv_func_getrlimit=no +export ac_cv_func_setrlimit=no +export ac_cv_func_sigaction=yes +export ac_cv_func_sigsetjmp=yes +export ac_cv_func_alarm=yes +export ac_cv_func_strtoll=yes +export ac_cv_func_fsetxattr=no +export ac_cv_func_ftruncate=yes +export ac_cv_func_sched_yield=yes +export ac_cv_func_sendmsg=yes +export ac_cv_func_recvmsg=yes +export ac_cv_func_getpass_r=no +export ac_cv_func_arc4random=no -# --- Configure --- -if [ ! -f Makefile ]; then - echo "==> Configuring curl for wasm32..." - - # Cross-compilation cache overrides. configure would otherwise - # try to link-test against the host libc. - export ac_cv_func_closesocket=no - export ac_cv_func_CloseSocket=no - export ac_cv_func_fcntl=yes - export ac_cv_func_ioctl=yes - export ac_cv_func_fchmod=yes - export ac_cv_func_fnmatch=yes - export ac_cv_func_basename=yes - export ac_cv_func_connect=yes - export ac_cv_func_gethostname=yes - export ac_cv_func_gettimeofday=yes - export ac_cv_func_poll=yes - export ac_cv_func_select=yes - export ac_cv_func_socket=yes - export ac_cv_func_socketpair=yes - export ac_cv_func_getifaddrs=no - export ac_cv_func_if_nametoindex=no - export ac_cv_func_freeifaddrs=no - export ac_cv_func_getpwuid=yes - export ac_cv_func_getpwuid_r=no - export ac_cv_func_getrlimit=no - export ac_cv_func_setrlimit=no - export ac_cv_func_sigaction=yes - export ac_cv_func_sigsetjmp=yes - export ac_cv_func_alarm=yes - export ac_cv_func_strtoll=yes - export ac_cv_func_fsetxattr=no - export ac_cv_func_ftruncate=yes - export ac_cv_func_sched_yield=yes - export ac_cv_func_sendmsg=yes - export ac_cv_func_recvmsg=yes - export ac_cv_func_getpass_r=no - export ac_cv_func_arc4random=no - - # Wasm32 type sizes. - export ac_cv_sizeof_long=4 - export ac_cv_sizeof_long_long=8 - export ac_cv_sizeof_int=4 - export ac_cv_sizeof_size_t=4 - export ac_cv_sizeof_off_t=8 - export ac_cv_sizeof_curl_off_t=8 - export ac_cv_sizeof_time_t=8 - - # Cross-compilation results that can't be detected at configure time. - export curl_cv_func_recv_args="int,void *,size_t,int,ssize_t" - export curl_cv_func_send_args="int,const void *,size_t,int,ssize_t" - export curl_cv_func_recvfrom_args="int,void *,size_t,int,struct sockaddr *,socklen_t *,ssize_t" - export curl_cv_recv="yes" - export curl_cv_send="yes" - - # Bypass OpenSSL link checks — cross-compilation can't run link - # tests against wasm32 static libraries. We know they exist and are - # valid because the resolver (or run.sh) produced them. - export ac_cv_lib_crypto_HMAC_Update=yes - export ac_cv_lib_crypto_HMAC_Init_ex=yes - export ac_cv_lib_ssl_SSL_connect=yes - export ac_cv_lib_crypto_EVP_DigestInit_ex=yes - export ac_cv_lib_dl_dlopen=yes - - # --- SSL / TLS --- - SSL_FLAGS=() - if [ -n "$OPENSSL_PREFIX" ]; then - SSL_FLAGS+=(--with-openssl="$OPENSSL_PREFIX") - # Provide explicit OpenSSL flags so configure doesn't fall back - # to pkg-config link tests (which would try to run host-linked - # binaries). - export OPENSSL_CFLAGS="-I$OPENSSL_PREFIX/include" - export OPENSSL_LIBS="-L$OPENSSL_PREFIX/lib -lssl -lcrypto" - echo "==> OpenSSL found at $OPENSSL_PREFIX, building with HTTPS support" - else - SSL_FLAGS+=(--without-ssl) - echo "==> OpenSSL not found, building HTTP-only (no SSL)" - fi +export ac_cv_sizeof_long=4 +export ac_cv_sizeof_long_long=8 +export ac_cv_sizeof_int=4 +export ac_cv_sizeof_size_t=4 +export ac_cv_sizeof_off_t=8 +export ac_cv_sizeof_curl_off_t=8 +export ac_cv_sizeof_time_t=8 - # --- zlib --- - ZLIB_FLAGS=() - if [ -n "$ZLIB_PREFIX" ]; then - ZLIB_FLAGS+=(--with-zlib="$ZLIB_PREFIX") - # curl 8.11.1's autoconf libz probes declare gzread/inflateEnd - # with old K&R-style signatures. That links to invalid wasm with - # the current SDK even though a correctly declared zlib program - # links cleanly, so cache the cross result once the resolver has - # provided an explicit zlib package path. - export ac_cv_lib_z_gzread=yes - export ac_cv_lib_z_inflateEnd=yes - echo "==> zlib found at $ZLIB_PREFIX" - else - ZLIB_FLAGS+=(--without-zlib) - echo "==> zlib not found, building without compression" - fi +export curl_cv_func_recv_args="int,void *,size_t,int,ssize_t" +export curl_cv_func_send_args="int,const void *,size_t,int,ssize_t" +export curl_cv_func_recvfrom_args="int,void *,size_t,int,struct sockaddr *,socklen_t *,ssize_t" +export curl_cv_recv=yes +export curl_cv_send=yes - # Static OpenSSL depends on -ldl for its dlopen stubs. - export LIBS="${LIBS:-} -ldl" +# These libraries are resolver-validated above. Their unprototyped autoconf +# probes are not valid evidence on Wasm's typed call graph. +export ac_cv_lib_crypto_HMAC_Update=yes +export ac_cv_lib_crypto_HMAC_Init_ex=yes +export ac_cv_lib_ssl_SSL_connect=yes +export ac_cv_lib_crypto_EVP_DigestInit_ex=yes +export ac_cv_lib_dl_dlopen=yes +export ac_cv_lib_z_gzread=yes +export ac_cv_lib_z_inflateEnd=yes - # Install prefix: the cache dir in resolver mode. Omit in legacy - # mode — we collect src/curl manually rather than `make install`. - PREFIX_ARGS=() - if [ "$RESOLVER_MODE" = "1" ]; then - PREFIX_ARGS+=(--prefix="$INSTALL_DIR") - fi +export OPENSSL_CFLAGS="-I$OPENSSL_PREFIX/include" +export OPENSSL_LIBS="-L$OPENSSL_PREFIX/lib -lssl -lcrypto" +export LIBS="${LIBS:-} -ldl" - wasm32posix-configure \ - ${PREFIX_ARGS[@]+"${PREFIX_ARGS[@]}"} \ - --disable-nls \ - --disable-shared \ - --enable-static \ - ${SSL_FLAGS[@]+"${SSL_FLAGS[@]}"} \ - ${ZLIB_FLAGS[@]+"${ZLIB_FLAGS[@]}"} \ - --without-brotli \ - --without-zstd \ - --without-nghttp2 \ - --without-libidn2 \ - --without-libssh2 \ - --without-librtmp \ - --without-winidn \ - --without-libpsl \ - --disable-ldap \ - --disable-ldaps \ - --disable-rtsp \ - --disable-dict \ - --disable-telnet \ - --disable-tftp \ - --disable-pop3 \ - --disable-imap \ - --disable-smb \ - --disable-smtp \ - --disable-gopher \ - --disable-mqtt \ - --disable-threaded-resolver \ - --disable-manual \ - --disable-docs \ - --disable-ntlm \ - --disable-unix-sockets \ - --without-libgsasl \ - --disable-tls-srp \ - 2>&1 | tail -40 - - echo "==> Configure complete." -fi +# libcurl.a is absorbed into PHP's curl.so side module. Compile every archive +# member as PIC, and map producer paths so the archive is reproducible across +# checkout and temporary-directory locations. +REPRO_FLAGS="-ffile-prefix-map=$WORK_DIR=/usr/src/curl-build" +REPRO_FLAGS+=" -fdebug-prefix-map=$WORK_DIR=/usr/src/curl-build" +REPRO_FLAGS+=" -fmacro-prefix-map=$WORK_DIR=/usr/src/curl-build" +REPRO_FLAGS+=" -ffile-prefix-map=$REPO_ROOT=/usr/src/kandelo" +REPRO_FLAGS+=" -fdebug-prefix-map=$REPO_ROOT=/usr/src/kandelo" +REPRO_FLAGS+=" -fmacro-prefix-map=$REPO_ROOT=/usr/src/kandelo" +REPRO_FLAGS+=" -ffile-prefix-map=$ZLIB_PREFIX=/usr/src/kandelo-deps/zlib" +REPRO_FLAGS+=" -fdebug-prefix-map=$ZLIB_PREFIX=/usr/src/kandelo-deps/zlib" +REPRO_FLAGS+=" -fmacro-prefix-map=$ZLIB_PREFIX=/usr/src/kandelo-deps/zlib" +REPRO_FLAGS+=" -ffile-prefix-map=$OPENSSL_PREFIX=/usr/src/kandelo-deps/openssl" +REPRO_FLAGS+=" -fdebug-prefix-map=$OPENSSL_PREFIX=/usr/src/kandelo-deps/openssl" +REPRO_FLAGS+=" -fmacro-prefix-map=$OPENSSL_PREFIX=/usr/src/kandelo-deps/openssl" + +export LC_ALL=C +export TZ=UTC +export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" +export ZERO_AR_DATE=1 + +echo "==> Configuring curl for wasm32..." +# curl 8.11.1 has no gettext/NLS configure option. Keep option checking fatal +# so a misspelled or copied option cannot silently make the build host-dependent. +PKG_CONFIG_PATH="$ZLIB_PREFIX/lib/pkgconfig:$OPENSSL_PREFIX/lib/pkgconfig" \ +CPPFLAGS="-I$ZLIB_PREFIX/include -I$OPENSSL_PREFIX/include" \ +LDFLAGS="-L$ZLIB_PREFIX/lib -L$OPENSSL_PREFIX/lib" \ +wasm32posix-configure \ + --enable-option-checking=fatal \ + --prefix=/usr \ + --disable-dependency-tracking \ + --disable-shared \ + --enable-static \ + --with-openssl="$OPENSSL_PREFIX" \ + --with-zlib="$ZLIB_PREFIX" \ + --without-brotli \ + --without-zstd \ + --without-nghttp2 \ + --without-libidn2 \ + --without-libssh2 \ + --without-librtmp \ + --without-winidn \ + --without-libpsl \ + --disable-ldap \ + --disable-ldaps \ + --disable-rtsp \ + --disable-dict \ + --disable-telnet \ + --disable-tftp \ + --disable-pop3 \ + --disable-imap \ + --disable-smb \ + --disable-smtp \ + --disable-gopher \ + --disable-mqtt \ + --disable-threaded-resolver \ + --disable-manual \ + --disable-docs \ + --disable-ntlm \ + --disable-unix-sockets \ + --without-libgsasl \ + --disable-tls-srp \ + CFLAGS="-O2 -fPIC $REPRO_FLAGS" -# --- Build --- echo "==> Building curl..." -make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" 2>&1 | tail -30 +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" -if [ "$RESOLVER_MODE" = "1" ]; then - # --- Install library + headers + pkgconfig into the cache --- - # curl's `make install` copies src/curl and curl-config into - # $INSTALL_DIR/bin/ and a manpage tree into $INSTALL_DIR/share/. - # The CLI is a consumer artifact, and curl-config is a host-shell - # shim that doesn't belong in the cached library output. - # - # When this script is invoked for the *curl* manifest (kind=program, - # outputs.wasm = "curl.wasm"), the resolver also expects - # `$INSTALL_DIR/curl.wasm` to exist post-build — so promote the CLI - # binary to a flat location at the install root before pruning bin/. - # When invoked for libcurl itself, the curl.wasm file is harmless - # ballast in the cache (libcurl's outputs validator only checks - # lib/libcurl.a + include/curl/*). - echo "==> Installing libcurl to $INSTALL_DIR..." - make install 2>&1 | tail -20 - if [ -f "$INSTALL_DIR/bin/curl" ]; then - cp "$INSTALL_DIR/bin/curl" "$INSTALL_DIR/curl.wasm" - fi - rm -rf "$INSTALL_DIR/bin" "$INSTALL_DIR/share" +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR" - if [ -f "$INSTALL_DIR/lib/libcurl.a" ]; then - echo "==> libcurl install complete!" - ls -lh "$INSTALL_DIR/lib/libcurl.a" - else - echo "ERROR: libcurl not installed at $INSTALL_DIR/lib/libcurl.a" >&2 +audit_wasm_imports() { + local wasm_path="$1" + local label="$2" + local import_dump declared_import_count wasm_imports parsed_import_count + local wasm_import required_import allowed candidate + local unexpected_imports=() + local allowed_imports=( + env.__channel_base + env.memory + kernel.kernel_apply_fork_fd_actions + kernel.kernel_argv_read + kernel.kernel_clear_fork_exec + kernel.kernel_environ_count + kernel.kernel_environ_get + kernel.kernel_execve + kernel.kernel_exit + kernel.kernel_get_argc + kernel.kernel_get_fork_exec_argc + kernel.kernel_get_fork_exec_argv + kernel.kernel_get_fork_exec_path + kernel.kernel_is_fork_child + kernel.kernel_push_argv + ) + + import_dump="$(wasm-objdump -x "$wasm_path")" + declared_import_count="$( + sed -n 's/^Import\[\([0-9][0-9]*\)\]:$/\1/p' <<<"$import_dump" | head -n 1 + )" + wasm_imports="$( + awk ' + /^Import\[[0-9]+\]:$/ { inside = 1; next } + inside && /^[[:alpha:]_][[:alnum:]_]*\[/ { exit } + inside && / <- / { sub(/^.* <- /, ""); print } + ' <<<"$import_dump" + )" + parsed_import_count="$(sed '/^$/d' <<<"$wasm_imports" | wc -l | tr -d ' ')" + if [ -z "$declared_import_count" ] || + [ "$parsed_import_count" -ne "$declared_import_count" ]; then + echo "ERROR: $label import audit could not account for every import: declared=${declared_import_count:-} parsed=$parsed_import_count" >&2 exit 1 fi -else - # --- Legacy mode: collect CLI, register with local-binaries/ --- - echo "==> Collecting binary..." - mkdir -p "$INSTALL_DIR" - - if [ -f "$SRC_DIR/src/curl" ]; then - cp "$SRC_DIR/src/curl" "$INSTALL_DIR/curl.wasm" - echo "==> Built curl" - ls -lh "$INSTALL_DIR/curl.wasm" - else - echo "ERROR: curl binary not found after build" >&2 + + while IFS= read -r wasm_import; do + [ -n "$wasm_import" ] || continue + allowed=0 + for candidate in "${allowed_imports[@]}"; do + if [ "$wasm_import" = "$candidate" ]; then + allowed=1 + break + fi + done + if [ "$allowed" -eq 0 ]; then + unexpected_imports+=("$wasm_import") + fi + done <<<"$wasm_imports" + for required_import in env.__channel_base env.memory; do + if ! grep -Fxq "$required_import" <<<"$wasm_imports"; then + echo "ERROR: $label is missing required import: $required_import" >&2 + exit 1 + fi + done + if [ "${#unexpected_imports[@]}" -ne 0 ]; then + echo "ERROR: $label has unexpected imports:" >&2 + printf '%s\n' "${unexpected_imports[@]}" >&2 exit 1 fi + echo "==> Validated $label import closure ($declared_import_count imports)" +} + +if [ "$RESOLVER_MODE" = "1" ]; then + case "$PACKAGE_NAME" in + libcurl) + make install DESTDIR="$STAGE_DIR" + mkdir -p "$INSTALL_DIR/lib/pkgconfig" "$INSTALL_DIR/include" + cp "$STAGE_DIR/usr/lib/libcurl.a" "$INSTALL_DIR/lib/" + cp -R "$STAGE_DIR/usr/include/curl" "$INSTALL_DIR/include/" + + # Keep pkg-config metadata relocatable and dependency-complete. + # PHP's configure reads the two custom variables as well as the + # normal static link flags. + supported_protocols="$(sed -n 's/^supported_protocols=//p' "$STAGE_DIR/usr/lib/pkgconfig/libcurl.pc")" + supported_features="$(sed -n 's/^supported_features=//p' "$STAGE_DIR/usr/lib/pkgconfig/libcurl.pc")" + cat > "$INSTALL_DIR/lib/pkgconfig/libcurl.pc" < curl built successfully!" - echo "Binary: $INSTALL_DIR/curl.wasm" +Name: libcurl +URL: https://curl.se/ +Description: Library to transfer files with URLs +Version: $CURL_VERSION +Requires.private: openssl zlib +Libs: -L\${libdir} -lcurl +Libs.private: -ldl +Cflags: -I\${includedir} -DCURL_STATICLIB +PCEOF - # Install into local-binaries/ so the resolver picks the freshly-built - # binary over the fetched release. + EXPECTED_OUTPUTS=( + lib/libcurl.a + lib/pkgconfig/libcurl.pc + include/curl/curl.h + include/curl/curlver.h + include/curl/easy.h + include/curl/header.h + include/curl/mprintf.h + include/curl/multi.h + include/curl/options.h + include/curl/stdcheaders.h + include/curl/system.h + include/curl/typecheck-gcc.h + include/curl/urlapi.h + include/curl/websockets.h + ) + output_files=() + for relative in "${EXPECTED_OUTPUTS[@]}"; do + if [ ! -s "$INSTALL_DIR/$relative" ]; then + echo "ERROR: libcurl declared output is missing or empty: $relative" >&2 + exit 1 + fi + output_files+=("$INSTALL_DIR/$relative") + done + actual_count="$(find "$INSTALL_DIR" -type f | wc -l | tr -d ' ')" + if [ "$actual_count" != "${#EXPECTED_OUTPUTS[@]}" ]; then + echo "ERROR: libcurl staged $actual_count files; expected only ${#EXPECTED_OUTPUTS[@]} declared outputs" >&2 + find "$INSTALL_DIR" -type f -print >&2 + exit 1 + fi + + symbols="$("$NM" "$INSTALL_DIR/lib/libcurl.a" 2>/dev/null)" + REQUIRED_LIBCURL_SYMBOLS=( + curl_easy_init + curl_easy_cleanup + curl_easy_perform + curl_version + curl_version_info + curl_multi_init + curl_share_init + ) + for required_symbol in "${REQUIRED_LIBCURL_SYMBOLS[@]}"; do + if ! grep -q " T ${required_symbol}\$" <<<"$symbols"; then + echo "ERROR: libcurl.a does not export $required_symbol" >&2 + exit 1 + fi + done + + # Pull every archive member into a real Kandelo executable. This + # makes non-PIC objects and otherwise-unused unresolved references + # visible before PHP absorbs libcurl.a into curl.so. + SMOKE_SOURCE="$WORK_DIR/libcurl-smoke.c" + SMOKE_WASM="$WORK_DIR/libcurl-smoke.wasm" + SMOKE_MEMBER_DIR="$WORK_DIR/libcurl-smoke-members" + cat > "$SMOKE_SOURCE" <<'EOF' +#include + +int main(void) { + CURL *handle = curl_easy_init(); + if (handle == NULL) { + return 1; + } + curl_easy_cleanup(handle); + return curl_version_info(CURLVERSION_NOW) == NULL ? 2 : 0; +} +EOF + mkdir -p "$SMOKE_MEMBER_DIR" + ( + cd "$SMOKE_MEMBER_DIR" + "$AR" x "$INSTALL_DIR/lib/libcurl.a" + ) + archive_member_count="$("$AR" t "$INSTALL_DIR/lib/libcurl.a" | wc -l | tr -d ' ')" + member_objects=() + while IFS= read -r member; do + member_objects+=("$member") + done < <(find "$SMOKE_MEMBER_DIR" -type f -print | LC_ALL=C sort) + if [ "$archive_member_count" -eq 0 ] || + [ "${#member_objects[@]}" -ne "$archive_member_count" ]; then + echo "ERROR: failed to extract every libcurl archive member: archive=$archive_member_count extracted=${#member_objects[@]}" >&2 + exit 1 + fi + "$CC" -O2 \ + -I"$INSTALL_DIR/include" \ + "$SMOKE_SOURCE" \ + -Wl,--no-gc-sections \ + "${member_objects[@]}" \ + "$ZLIB_PREFIX/lib/libz.a" \ + "$OPENSSL_PREFIX/lib/libssl.a" \ + "$OPENSSL_PREFIX/lib/libcrypto.a" \ + -ldl \ + -o "$SMOKE_WASM" + audit_wasm_imports "$SMOKE_WASM" "libcurl full-member smoke Wasm" + + for forbidden in "$WORK_DIR" "$REPO_ROOT" "$ZLIB_PREFIX" "$OPENSSL_PREFIX" "$INSTALL_DIR"; do + if grep -aFq "$forbidden" "${output_files[@]}"; then + echo "ERROR: libcurl output contains producer path: $forbidden" >&2 + exit 1 + fi + done + echo "==> Validated $archive_member_count libcurl members and ${#EXPECTED_OUTPUTS[@]} exact outputs" + ;; + curl) + cp src/curl "$INSTALL_DIR/curl.wasm" + chmod 0755 "$INSTALL_DIR/curl.wasm" + if [ ! -s "$INSTALL_DIR/curl.wasm" ] || + [ "$(find "$INSTALL_DIR" -type f | wc -l | tr -d ' ')" -ne 1 ]; then + echo "ERROR: curl package must contain only non-empty curl.wasm" >&2 + find "$INSTALL_DIR" -type f -print >&2 + exit 1 + fi + audit_wasm_imports "$INSTALL_DIR/curl.wasm" "curl CLI Wasm" + for forbidden in "$WORK_DIR" "$REPO_ROOT" "$ZLIB_PREFIX" "$OPENSSL_PREFIX" "$INSTALL_DIR"; do + if grep -aFq "$forbidden" "$INSTALL_DIR/curl.wasm"; then + echo "ERROR: curl.wasm contains producer path: $forbidden" >&2 + exit 1 + fi + done + ;; + *) + echo "ERROR: unsupported resolver package name: $PACKAGE_NAME" >&2 + exit 1 + ;; + esac +else + cp src/curl "$INSTALL_DIR/curl.wasm" + chmod 0755 "$INSTALL_DIR/curl.wasm" source "$REPO_ROOT/scripts/install-local-binary.sh" install_local_binary curl "$INSTALL_DIR/curl.wasm" fi + + echo "==> $PACKAGE_NAME outputs installed at $INSTALL_DIR" + find "$INSTALL_DIR" -type f -exec shasum -a 256 {} + diff --git a/packages/registry/libcurl/build.toml b/packages/registry/libcurl/build.toml index 741fad36bc..7a31ae4734 100644 --- a/packages/registry/libcurl/build.toml +++ b/packages/registry/libcurl/build.toml @@ -1,7 +1,10 @@ script_path = "packages/registry/libcurl/build-libcurl.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 2 +inputs = [ + "packages/registry/libcurl/build-libcurl.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/libcurl/package.toml b/packages/registry/libcurl/package.toml index 311229f3f9..7cbedd0378 100644 --- a/packages/registry/libcurl/package.toml +++ b/packages/registry/libcurl/package.toml @@ -3,9 +3,10 @@ # The logical name is "libcurl" (matches the directory). Consumers # reference it as `libcurl@8.11.1` in their own `depends_on`, and the # resolver surfaces the install path via `WASM_POSIX_DEP_LIBCURL_DIR`. -# The curl CLI lives outside the resolver's scope — it's a consumer -# artifact, not a library — and is still built in legacy mode (see -# `build-libcurl.sh`). +# This manifest publishes only the library package. The separate `curl` +# program manifest invokes the same upstream build recipe in its own resolver +# transaction and publishes only curl.wasm; direct legacy invocations remain +# available to run.sh callers. kind = "library" @@ -13,6 +14,7 @@ name = "libcurl" version = "8.11.1" kernel_abi = 7 depends_on = ["zlib@1.3.1", "openssl@3.3.2"] +arches = ["wasm32"] [source] url = "https://curl.se/download/curl-8.11.1.tar.xz" @@ -25,6 +27,36 @@ url = "https://curl.se/docs/copyright.html" [build] script_path = "packages/registry/libcurl/build-libcurl.sh" +[[host_tools]] +name = "curl" +version_constraint = ">=7.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "make" +version_constraint = ">=3.80" +probe = { args = ["--version"], version_regex = "GNU Make (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "tar" +version_constraint = ">=1.30" +probe = { args = ["--version"], version_regex = "tar.*?(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "shasum" +version_constraint = ">=6.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "wasm-objdump" +version_constraint = ">=1.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + [outputs] libs = ["lib/libcurl.a"] # curl installs ~12 headers under include/curl/. Declare the directory diff --git a/packages/registry/libcxx/.gitignore b/packages/registry/libcxx/.gitignore index 201dd024e5..d13a937fee 100644 --- a/packages/registry/libcxx/.gitignore +++ b/packages/registry/libcxx/.gitignore @@ -1,5 +1,7 @@ node_modules/ # LLVM source clone (per major version) — build script populates on demand. llvm-project-*/ +# Per-arch LLVM source symlink tree the build script recreates each run. +llvm-source-*/ # Per-arch build trees produced by the build script. build-*/ diff --git a/packages/registry/libcxx/build-libcxx.sh b/packages/registry/libcxx/build-libcxx.sh index 96396ed8f7..22dd7420ea 100755 --- a/packages/registry/libcxx/build-libcxx.sh +++ b/packages/registry/libcxx/build-libcxx.sh @@ -103,7 +103,10 @@ if [ ! -d "$NIX_LIBUNWIND_SOURCE/libunwind" ]; then fi BUILD_DIR="$SCRIPT_DIR/build-${ARCH}" -LLVM_SRC_DIR="$BUILD_DIR/llvm-source" +# Assembled source tree lives OUTSIDE the build dirs so both the default +# (static, non-PIC) build and the position-independent build below can share it +# without one build's `rm -rf` deleting the other's source. +LLVM_SRC_DIR="$SCRIPT_DIR/llvm-source-${ARCH}" # --- Verify prerequisites --- if [ ! -f "$SYSROOT/lib/libc.a" ]; then @@ -134,12 +137,19 @@ echo "==> Building libc++ and libc++abi for ${ARCH}..." # against the modern ABI; consumers linking against this libcxx # archive must also compile with `-wasm-use-legacy-eh=false` (the # SDK's `compileFlags` was updated in lock-step). -WASM_C_FLAGS="--target=${WASM_TARGET} -matomics -mbulk-memory -mexception-handling -mllvm -wasm-enable-sjlj -mllvm -wasm-use-legacy-eh=false -fexceptions -fno-trapping-math --sysroot=${SYSROOT} -O2 -DNDEBUG" +# +# LLVM records source/compilation paths in archive members. All assembled +# sources and both variant build directories live under REPO_ROOT, so one +# stable worktree mapping covers LLVM_SRC_DIR, BUILD_DIR, PIC_BUILD_DIR, the +# generated smoke source, and sysroot paths for both PIC and non-PIC builds. +# Without it, libc++abi.a and every side module absorbing it differ solely by +# the caller's checkout path. +REPRODUCIBLE_PREFIX_MAPS="-ffile-prefix-map=${REPO_ROOT}=/usr/src/kandelo -fdebug-prefix-map=${REPO_ROOT}=/usr/src/kandelo -fmacro-prefix-map=${REPO_ROOT}=/usr/src/kandelo" +WASM_C_FLAGS="--target=${WASM_TARGET} -matomics -mbulk-memory -mexception-handling -mllvm -wasm-enable-sjlj -mllvm -wasm-use-legacy-eh=false -fexceptions -fno-trapping-math --sysroot=${SYSROOT} -O2 -DNDEBUG ${REPRODUCIBLE_PREFIX_MAPS}" -# Always start with a fresh build tree so a cache-miss rebuild does -# not mix old + new cmake artifacts. -rm -rf "$BUILD_DIR" -mkdir -p "$BUILD_DIR" +# Start with a fresh source tree so a cache-miss rebuild does not mix old + new +# artifacts. (Each build dir is cleaned by build_libcxx_variant below.) +rm -rf "$LLVM_SRC_DIR" # Assemble the monorepo-shaped source tree expected by runtimes/CMakeLists.txt # from exact Nix source derivations. Nix's libcxx source carries runtimes/, @@ -160,63 +170,77 @@ for entry in "$NIX_LIBCXX_SOURCE/runtimes"/*; do done ln -s "$NIX_LIBUNWIND_SOURCE/libunwind" "$LLVM_SRC_DIR/libunwind" -cd "$BUILD_DIR" +NPROC="$(sysctl -n hw.ncpu 2>/dev/null || nproc)" -cmake -G "Unix Makefiles" -S "$LLVM_SRC_DIR/runtimes" \ - -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind" \ - -DCMAKE_SYSTEM_NAME=Generic \ - -DCMAKE_SYSTEM_PROCESSOR="${ARCH}" \ - -DCMAKE_C_COMPILER="$LLVM_CLANG" \ - -DCMAKE_CXX_COMPILER="$LLVM_CLANG" \ - -DCMAKE_AR="$LLVM_AR" \ - -DCMAKE_RANLIB="$LLVM_RANLIB" \ - -DCMAKE_NM="$LLVM_NM" \ - -DCMAKE_C_COMPILER_TARGET="${WASM_TARGET}" \ - -DCMAKE_CXX_COMPILER_TARGET="${WASM_TARGET}" \ - -DCMAKE_C_FLAGS="${WASM_C_FLAGS}" \ - -DCMAKE_CXX_FLAGS="${WASM_C_FLAGS}" \ - -DCMAKE_SYSROOT="${SYSROOT}" \ - -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ - \ - -DLIBCXX_ENABLE_SHARED=OFF \ - -DLIBCXX_ENABLE_STATIC=ON \ - -DLIBCXX_ENABLE_EXCEPTIONS=ON \ - -DLIBCXX_ENABLE_RTTI=ON \ - -DLIBCXX_HAS_MUSL_LIBC=ON \ - -DLIBCXX_HAS_PTHREAD_API=ON \ - -DLIBCXX_CXX_ABI=libcxxabi \ - -DLIBCXX_INCLUDE_BENCHMARKS=OFF \ - -DLIBCXX_INCLUDE_TESTS=OFF \ - -DLIBCXX_ENABLE_FILESYSTEM=ON \ - -DLIBCXX_ENABLE_MONOTONIC_CLOCK=ON \ - -DLIBCXX_ENABLE_RANDOM_DEVICE=OFF \ - -DLIBCXX_ENABLE_LOCALIZATION=ON \ - -DLIBCXX_ENABLE_WIDE_CHARACTERS=ON \ - -DLIBCXX_ENABLE_NEW_DELETE_DEFINITIONS=ON \ - \ - -DLIBCXXABI_ENABLE_SHARED=OFF \ - -DLIBCXXABI_ENABLE_STATIC=ON \ - -DLIBCXXABI_ENABLE_EXCEPTIONS=ON \ - -DLIBCXXABI_USE_LLVM_UNWINDER=ON \ - -DLIBCXXABI_ENABLE_STATIC_UNWINDER=ON \ - -DLIBCXXABI_STATICALLY_LINK_UNWINDER_IN_STATIC_LIBRARY=ON \ - -DLIBCXXABI_ENABLE_THREADS=ON \ - -DLIBCXXABI_HAS_PTHREAD_API=ON \ - -DLIBCXXABI_INCLUDE_TESTS=OFF \ - \ - -DLIBUNWIND_ENABLE_SHARED=OFF \ - -DLIBUNWIND_ENABLE_STATIC=ON \ - -DLIBUNWIND_ENABLE_THREADS=ON \ - -DLIBUNWIND_USE_COMPILER_RT=OFF \ - -DLIBUNWIND_INCLUDE_TESTS=OFF \ - -DLIBUNWIND_HIDE_SYMBOLS=ON \ - \ - -DCMAKE_SIZEOF_VOID_P="${SIZEOF_VOID_P}" \ - 2>&1 | tail -20 +# Configure + build libc++/libc++abi/libunwind into with the given +# compile-flags string (plus any extra cmake args). Factored so the default +# static archives and the position-independent variant (below) share ONE cmake +# recipe and cannot drift apart. +build_libcxx_variant() { + local variant_build_dir="$1"; shift + local variant_c_flags="$1"; shift + rm -rf "$variant_build_dir" + mkdir -p "$variant_build_dir" + ( cd "$variant_build_dir" + cmake -G "Unix Makefiles" -S "$LLVM_SRC_DIR/runtimes" \ + -DLLVM_ENABLE_RUNTIMES="libcxx;libcxxabi;libunwind" \ + -DCMAKE_SYSTEM_NAME=Generic \ + -DCMAKE_SYSTEM_PROCESSOR="${ARCH}" \ + -DCMAKE_C_COMPILER="$LLVM_CLANG" \ + -DCMAKE_CXX_COMPILER="$LLVM_CLANG" \ + -DCMAKE_AR="$LLVM_AR" \ + -DCMAKE_RANLIB="$LLVM_RANLIB" \ + -DCMAKE_NM="$LLVM_NM" \ + -DCMAKE_C_COMPILER_TARGET="${WASM_TARGET}" \ + -DCMAKE_CXX_COMPILER_TARGET="${WASM_TARGET}" \ + -DCMAKE_C_FLAGS="${variant_c_flags}" \ + -DCMAKE_CXX_FLAGS="${variant_c_flags}" \ + -DCMAKE_SYSROOT="${SYSROOT}" \ + -DCMAKE_TRY_COMPILE_TARGET_TYPE=STATIC_LIBRARY \ + \ + -DLIBCXX_ENABLE_SHARED=OFF \ + -DLIBCXX_ENABLE_STATIC=ON \ + -DLIBCXX_ENABLE_EXCEPTIONS=ON \ + -DLIBCXX_ENABLE_RTTI=ON \ + -DLIBCXX_HAS_MUSL_LIBC=ON \ + -DLIBCXX_HAS_PTHREAD_API=ON \ + -DLIBCXX_CXX_ABI=libcxxabi \ + -DLIBCXX_INCLUDE_BENCHMARKS=OFF \ + -DLIBCXX_INCLUDE_TESTS=OFF \ + -DLIBCXX_ENABLE_FILESYSTEM=ON \ + -DLIBCXX_ENABLE_MONOTONIC_CLOCK=ON \ + -DLIBCXX_ENABLE_RANDOM_DEVICE=OFF \ + -DLIBCXX_ENABLE_LOCALIZATION=ON \ + -DLIBCXX_ENABLE_WIDE_CHARACTERS=ON \ + -DLIBCXX_ENABLE_NEW_DELETE_DEFINITIONS=ON \ + \ + -DLIBCXXABI_ENABLE_SHARED=OFF \ + -DLIBCXXABI_ENABLE_STATIC=ON \ + -DLIBCXXABI_ENABLE_EXCEPTIONS=ON \ + -DLIBCXXABI_USE_LLVM_UNWINDER=ON \ + -DLIBCXXABI_ENABLE_STATIC_UNWINDER=ON \ + -DLIBCXXABI_STATICALLY_LINK_UNWINDER_IN_STATIC_LIBRARY=ON \ + -DLIBCXXABI_ENABLE_THREADS=ON \ + -DLIBCXXABI_HAS_PTHREAD_API=ON \ + -DLIBCXXABI_INCLUDE_TESTS=OFF \ + \ + -DLIBUNWIND_ENABLE_SHARED=OFF \ + -DLIBUNWIND_ENABLE_STATIC=ON \ + -DLIBUNWIND_ENABLE_THREADS=ON \ + -DLIBUNWIND_USE_COMPILER_RT=OFF \ + -DLIBUNWIND_INCLUDE_TESTS=OFF \ + -DLIBUNWIND_HIDE_SYMBOLS=ON \ + \ + -DCMAKE_SIZEOF_VOID_P="${SIZEOF_VOID_P}" \ + "$@" \ + 2>&1 | tail -20 -echo "==> Compiling (this may take a few minutes)..." -NPROC="$(sysctl -n hw.ncpu 2>/dev/null || nproc)" -make -j"$NPROC" cxx cxxabi unwind 2>&1 | tail -10 + echo "==> Compiling (this may take a few minutes)..." + make -j"$NPROC" cxx cxxabi unwind 2>&1 | tail -10 ) +} + +echo "==> Building default (static, non-PIC) libc++/libc++abi for ${ARCH}..." +build_libcxx_variant "$BUILD_DIR" "${WASM_C_FLAGS}" # --- Install into the resolver's OUT_DIR --- echo "==> Installing to $INSTALL_DIR..." @@ -298,7 +322,32 @@ if ! "$LLVM_CLANG" ${WASM_C_FLAGS} \ fi echo "==> Header smoke compile passed." +# --- Position-independent variant for wasm side modules --- +# The default archives above are non-PIC, which is correct for the common case: +# static linking into a main wasm module (php.wasm, mariadb, ruby). But a wasm +# SIDE MODULE (built with `-shared --experimental-pic`, e.g. PHP's intl.so, which +# statically absorbs libc++/libc++abi) requires EVERY input object to be +# position-independent, or wasm-ld fails with "relocation R_WASM_MEMORY_ADDR_SLEB +# cannot be used against symbol ...; recompile with -fPIC". Emit a parallel PIC +# pair alongside the defaults. This is purely additive: libc++.a / libc++abi.a +# and the header set above are untouched, so existing static consumers are +# unaffected; only side-module consumers reach for the -pic archives. +echo "==> Building position-independent libc++/libc++abi (for wasm side modules)..." +PIC_BUILD_DIR="$SCRIPT_DIR/build-${ARCH}-pic" +build_libcxx_variant "$PIC_BUILD_DIR" "${WASM_C_FLAGS} -fPIC" -DCMAKE_POSITION_INDEPENDENT_CODE=ON + +LIBCXX_PIC_A=$(find "$PIC_BUILD_DIR" -name "libc++.a" -not -path "*/CMakeFiles/*" | head -1) +LIBCXXABI_PIC_A=$(find "$PIC_BUILD_DIR" -name "libc++abi.a" -not -path "*/CMakeFiles/*" | head -1) +if [ -z "$LIBCXX_PIC_A" ] || [ -z "$LIBCXXABI_PIC_A" ]; then + echo "ERROR: PIC libraries not found under $PIC_BUILD_DIR" >&2 + exit 1 +fi +cp "$LIBCXX_PIC_A" "$INSTALL_DIR/lib/libc++-pic.a" +cp "$LIBCXXABI_PIC_A" "$INSTALL_DIR/lib/libc++abi-pic.a" + echo "==> Done!" -echo " libc++.a: $(wc -c < "$INSTALL_DIR/lib/libc++.a" | tr -d ' ') bytes" -echo " libc++abi.a: $(wc -c < "$INSTALL_DIR/lib/libc++abi.a" | tr -d ' ') bytes" -echo " headers: $INSTALL_DIR/include/c++/v1/" +echo " libc++.a: $(wc -c < "$INSTALL_DIR/lib/libc++.a" | tr -d ' ') bytes" +echo " libc++abi.a: $(wc -c < "$INSTALL_DIR/lib/libc++abi.a" | tr -d ' ') bytes" +echo " libc++-pic.a: $(wc -c < "$INSTALL_DIR/lib/libc++-pic.a" | tr -d ' ') bytes" +echo " libc++abi-pic.a: $(wc -c < "$INSTALL_DIR/lib/libc++abi-pic.a" | tr -d ' ') bytes" +echo " headers: $INSTALL_DIR/include/c++/v1/" diff --git a/packages/registry/libcxx/build.toml b/packages/registry/libcxx/build.toml index 875768a9eb..40b16e4ef8 100644 --- a/packages/registry/libcxx/build.toml +++ b/packages/registry/libcxx/build.toml @@ -1,13 +1,19 @@ script_path = "packages/registry/libcxx/build-libcxx.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" # Revision 4: libcxx + libcxxabi + libunwind rebuilt with # `-mllvm -wasm-use-legacy-eh=false` explicitly set. # Revision 5: libcxx builds only from flake.nix's exact Nix LLVM # source derivations, hard-fails on compiler/source version drift, and # installs headers from the build tree so the header set cannot drift # from the built library. -revision = 5 +# Revision 6: additionally emits position-independent libc++-pic.a / +# libc++abi-pic.a (a second -fPIC build) so wasm side modules like PHP's +# intl.so can statically absorb libc++. The non-PIC pair is unchanged. +# Revision 7: map the worktree (including the assembled LLVM source and both +# build variants) to a stable producer path so archives do not retain the +# caller's absolute checkout directory. +revision = 7 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/libcxx/package.toml b/packages/registry/libcxx/package.toml index 49d4065708..9fcc01e8c3 100644 --- a/packages/registry/libcxx/package.toml +++ b/packages/registry/libcxx/package.toml @@ -42,5 +42,11 @@ url = "https://github.com/llvm/llvm-project/blob/main/LICENSE.TXT" script_path = "packages/registry/libcxx/build-libcxx.sh" [outputs] -libs = ["lib/libc++.a", "lib/libc++abi.a"] +# libc++.a / libc++abi.a are the default non-PIC archives for static linking +# into a main wasm module (php.wasm, mariadb, ruby). libc++-pic.a / +# libc++abi-pic.a are the position-independent variants required by wasm SIDE +# MODULES (`-shared --experimental-pic`), e.g. PHP's intl.so, which statically +# absorbs libc++ and would otherwise hit a wasm-ld "recompile with -fPIC" error. +# Additive: existing consumers keep using the non-PIC pair unchanged. +libs = ["lib/libc++.a", "lib/libc++abi.a", "lib/libc++-pic.a", "lib/libc++abi-pic.a"] headers = ["include/c++/v1"] diff --git a/packages/registry/libiconv/build-libiconv.sh b/packages/registry/libiconv/build-libiconv.sh new file mode 100755 index 0000000000..a4ddb5d2d5 --- /dev/null +++ b/packages/registry/libiconv/build-libiconv.sh @@ -0,0 +1,97 @@ +#!/usr/bin/env bash +# +# Build GNU libiconv for wasm32-posix-kernel. +# +# Honors the dep-resolver build-script contract. Resolver-provided builds set: +# WASM_POSIX_DEP_OUT_DIR +# WASM_POSIX_DEP_VERSION +# WASM_POSIX_DEP_SOURCE_URL +# WASM_POSIX_DEP_SOURCE_SHA256 +# +# Legacy invocation installs into packages/registry/libiconv/libiconv-install. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-libiconv.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT +SRC_DIR="$WORK_DIR/source" +STAGE_DIR="$WORK_DIR/stage" + +# Worktree-local SDK on PATH (no global npm link required). +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +LIBICONV_VERSION="${WASM_POSIX_DEP_VERSION:-${LIBICONV_VERSION:-1.17}}" +INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/libiconv-install}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://ftp.gnu.org/pub/gnu/libiconv/libiconv-${LIBICONV_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-8f74213b56238c85a50a5329f77e06198771e70dd9a739779f4c02f65d971313}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: libiconv currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi + +if ! command -v wasm32posix-cc &>/dev/null; then + echo "ERROR: wasm32posix-cc not found. Run inside nix develop or source sdk/activate.sh with LLVM available." >&2 + exit 1 +fi + +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" + +echo "==> Downloading GNU libiconv $LIBICONV_VERSION..." +TARBALL="$WORK_DIR/libiconv.tar.gz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +cd "$SRC_DIR" +rm -rf "$INSTALL_DIR" + +echo "==> Configuring GNU libiconv for Wasm..." +wasm32posix-configure \ + --disable-shared \ + --enable-static \ + --disable-nls \ + --prefix=/usr \ + CFLAGS="-O2" + +echo "==> Building GNU libiconv..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" + +echo "==> Staging declared package outputs..." +make install DESTDIR="$STAGE_DIR" +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR/lib" "$INSTALL_DIR/include" +cp "$STAGE_DIR/usr/lib/libiconv.a" "$INSTALL_DIR/lib/" +cp "$STAGE_DIR/usr/lib/libcharset.a" "$INSTALL_DIR/lib/" +cp "$STAGE_DIR/usr/include/iconv.h" "$INSTALL_DIR/include/" +cp "$STAGE_DIR/usr/include/libcharset.h" "$INSTALL_DIR/include/" +cp "$STAGE_DIR/usr/include/localcharset.h" "$INSTALL_DIR/include/" + +mkdir -p "$INSTALL_DIR/lib/pkgconfig" +cat > "$INSTALL_DIR/lib/pkgconfig/libiconv.pc" < GNU libiconv build complete!" + ls -lh "$INSTALL_DIR/lib/libiconv.a" "$INSTALL_DIR/lib/libcharset.a" +else + echo "ERROR: Build failed — libiconv/libcharset archive missing" >&2 + exit 1 +fi diff --git a/packages/registry/libiconv/build.toml b/packages/registry/libiconv/build.toml new file mode 100644 index 0000000000..95d90084d0 --- /dev/null +++ b/packages/registry/libiconv/build.toml @@ -0,0 +1,7 @@ +script_path = "packages/registry/libiconv/build-libiconv.sh" +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 2 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/libiconv/package.json b/packages/registry/libiconv/package.json new file mode 100644 index 0000000000..4692d0e1c1 --- /dev/null +++ b/packages/registry/libiconv/package.json @@ -0,0 +1,15 @@ +{ + "name": "wasm-posix-libiconv-example", + "version": "0.0.1", + "private": true, + "type": "module", + "scripts": { + "build-libiconv": "bash build-libiconv.sh", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "typescript": "^5.9.3", + "vitest": "^3.2.0" + } +} diff --git a/packages/registry/libiconv/package.toml b/packages/registry/libiconv/package.toml new file mode 100644 index 0000000000..b617c8495a --- /dev/null +++ b/packages/registry/libiconv/package.toml @@ -0,0 +1,27 @@ +# Per-library manifest for GNU libiconv. See docs/package-management.md. + +kind = "library" + +name = "libiconv" +version = "1.17" +kernel_abi = 7 +depends_on = [] +# The build script currently uses the wasm32 SDK explicitly. Do not advertise +# a wasm64 artifact until the recipe is architecture-aware and validated. +arches = ["wasm32"] + +[source] +url = "https://ftp.gnu.org/pub/gnu/libiconv/libiconv-1.17.tar.gz" +sha256 = "8f74213b56238c85a50a5329f77e06198771e70dd9a739779f4c02f65d971313" + +[license] +spdx = "LGPL-2.1-or-later AND GPL-3.0-or-later" +url = "https://git.savannah.gnu.org/cgit/libiconv.git/tree/COPYING?h=v1.17" + +[build] +script_path = "packages/registry/libiconv/build-libiconv.sh" + +[outputs] +libs = ["lib/libiconv.a", "lib/libcharset.a"] +headers = ["include/iconv.h", "include/libcharset.h", "include/localcharset.h"] +pkgconfig = ["lib/pkgconfig/libiconv.pc"] diff --git a/packages/registry/libxml2/build-libxml2.sh b/packages/registry/libxml2/build-libxml2.sh index e0d8670d04..36d4c1ce7d 100644 --- a/packages/registry/libxml2/build-libxml2.sh +++ b/packages/registry/libxml2/build-libxml2.sh @@ -12,6 +12,7 @@ # WASM_POSIX_DEP_SOURCE_URL # tarball URL # WASM_POSIX_DEP_SOURCE_SHA256 # expected sha256 of the tarball # WASM_POSIX_DEP_ZLIB_DIR # resolved zlib prefix (direct dep) +# WASM_POSIX_DEP_LIBICONV_DIR # resolved GNU libiconv prefix (direct dep) # # For ad-hoc / legacy invocation (`bash build-libxml2.sh`), the script # falls back to the in-tree `libxml2-install/` layout and to a @@ -26,24 +27,43 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/libxml2-src" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-libxml2.XXXXXX")" +cleanup() { + if [ "${WASM_POSIX_KEEP_BUILD_DIR:-0}" = "1" ]; then + echo "==> Preserving libxml2 build directory: $WORK_DIR" >&2 + else + rm -rf "$WORK_DIR" + fi +} +trap cleanup EXIT +SRC_DIR="$WORK_DIR/source" + +# Worktree-local SDK on PATH (no global npm link required). +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" # --- Inputs from resolver, with legacy fallbacks --- LIBXML2_VERSION="${WASM_POSIX_DEP_VERSION:-${LIBXML2_VERSION:-2.13.8}}" LIBXML2_MAJOR_MINOR="${LIBXML2_VERSION%.*}" INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/libxml2-install}" SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://download.gnome.org/sources/libxml2/${LIBXML2_MAJOR_MINOR}/libxml2-${LIBXML2_VERSION}.tar.xz}" -SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-277294cb33119ab71b2bc81f2f445e9bc9435b893ad15bb2cd2b0e859a0ee84a}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: libxml2 currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 + echo "ERROR: wasm32posix-cc not found after sourcing sdk/activate.sh." >&2 exit 1 fi SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" export WASM_POSIX_SYSROOT="$SYSROOT" -# --- Locate zlib --- +# --- Locate zlib / libiconv --- # Resolver surfaces the direct-dep install path via contract env var. # Legacy mode falls back to the sibling zlib-install dir that # `build-zlib.sh` lays down (also our historical layout). @@ -62,38 +82,84 @@ if [ ! -f "$ZLIB_PREFIX/lib/libz.a" ]; then exit 1 fi -# --- Fetch + verify source --- -if [ ! -d "$SRC_DIR" ]; then - echo "==> Downloading libxml2 $LIBXML2_VERSION..." - TARBALL="/tmp/libxml2-${LIBXML2_VERSION}.tar.xz" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" - if [ -n "$SOURCE_SHA256" ]; then - echo "==> Verifying source sha256..." - echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - - else - echo "==> (no SOURCE_SHA256 declared; skipping verification)" +LIBICONV_PREFIX="${WASM_POSIX_DEP_LIBICONV_DIR:-}" +if [ -z "$LIBICONV_PREFIX" ]; then + LEGACY_LIBICONV="$SCRIPT_DIR/../libiconv/libiconv-install" + if [ ! -f "$LEGACY_LIBICONV/lib/libiconv.a" ]; then + echo "==> Building GNU libiconv (legacy path)..." + bash "$SCRIPT_DIR/../libiconv/build-libiconv.sh" fi - mkdir -p "$SRC_DIR" - tar xJf "$TARBALL" -C "$SRC_DIR" --strip-components=1 - rm "$TARBALL" + LIBICONV_PREFIX="$LEGACY_LIBICONV" +fi + +if [ ! -f "$LIBICONV_PREFIX/lib/libiconv.a" ]; then + echo "ERROR: GNU libiconv not found at $LIBICONV_PREFIX" >&2 + exit 1 fi +# --- Fetch + verify source --- +echo "==> Downloading libxml2 $LIBXML2_VERSION..." +TARBALL="$WORK_DIR/libxml2.tar.xz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xJf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + cd "$SRC_DIR" -# --- Configure (regenerate config.h against the current ZLIB_PREFIX) --- -# Scrub any stale config so probes re-run. Cheap; no object-compile wasted. +# --- Configure against the resolver-provided dependencies --- echo "==> Configuring libxml2 for Wasm (zlib at $ZLIB_PREFIX)..." -make distclean 2>/dev/null || true -rm -f config.h config.status -wasm32posix-configure \ +DEP_PKG_CONFIG_PATH="$ZLIB_PREFIX/lib/pkgconfig:$LIBICONV_PREFIX/lib/pkgconfig" +if [ -n "${WASM_POSIX_DEP_PKG_CONFIG_PATH:-}" ]; then + DEP_PKG_CONFIG_PATH="$DEP_PKG_CONFIG_PATH:$WASM_POSIX_DEP_PKG_CONFIG_PATH" +fi +if [ -n "${PKG_CONFIG_PATH:-}" ]; then + DEP_PKG_CONFIG_PATH="$DEP_PKG_CONFIG_PATH:$PKG_CONFIG_PATH" +fi + +# Autoconf's unprototyped AC_CHECK_LIB calls can produce temporarily +# signature-mismatched Wasm that Binaryen rightly rejects. Keep configure +# probes unoptimized; the actual archive is compiled at -O2 below. +PKG_CONFIG_PATH="$DEP_PKG_CONFIG_PATH" wasm32posix-configure \ --disable-shared --enable-static \ - --without-python --without-readline --without-iconv \ + --without-python --without-readline \ --without-icu --without-lzma --without-http --without-ftp \ --without-threads \ --with-zlib="$ZLIB_PREFIX" \ - --prefix="$INSTALL_DIR" \ - CFLAGS="-O2" + --with-iconv="$LIBICONV_PREFIX" \ + --prefix=/usr \ + CPPFLAGS="-I$ZLIB_PREFIX/include -I$LIBICONV_PREFIX/include" \ + CFLAGS="-O0" \ + LDFLAGS="-L$ZLIB_PREFIX/lib -L$LIBICONV_PREFIX/lib" + +if ! awk ' + /^#if 1$/ { enabled = 1; next } + enabled && /^#define LIBXML_ZLIB_ENABLED$/ { found = 1 } + /^#endif$/ { enabled = 0 } + END { exit found ? 0 : 1 } +' include/libxml/xmlversion.h; then + echo "ERROR: libxml2 configure did not enable declared zlib support" >&2 + exit 1 +fi + +# libxml2 2.13.8's debug shell uses POSIX access(2) without including +# . Native builds can inherit an implicit declaration from permissive +# compiler modes; Kandelo's C99 cross-build rejects that upstream omission. +if ! grep -q "kandelo-posix-access-declaration" debugXML.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("debugXML.c") +s = p.read_text() +marker = "#include \n" +replacement = marker + "#include /* kandelo-posix-access-declaration */\n" +if marker not in s: + raise SystemExit("libxml2 debugXML include marker not found") +p.write_text(s.replace(marker, replacement, 1)) +PY +fi # Compile directly without libtool. Source list mirrors Makefile.am's # libxml2_la_SOURCES plus the modules our `configure` run enables. @@ -103,6 +169,7 @@ SOURCES=( SAX.c SAX2.c threads.c tree.c uri.c valid.c xmlIO.c xmlmemory.c xmlstring.c c14n.c catalog.c + debugXML.c HTMLparser.c HTMLtree.c legacy.c pattern.c relaxng.c @@ -112,17 +179,19 @@ SOURCES=( schematron.c ) -CFLAGS="-O2 -DHAVE_CONFIG_H -I. -I./include" +CFLAGS="-O2 -DHAVE_CONFIG_H -I. -I./include -I$ZLIB_PREFIX/include -I$LIBICONV_PREFIX/include" echo "==> Compiling libxml2 source files..." OBJS=() for src in "${SOURCES[@]}"; do - if [ -f "$src" ]; then - obj="${src%.c}.o" - # shellcheck disable=SC2086 - wasm32posix-cc $CFLAGS -c "$src" -o "$obj" - OBJS+=("$obj") + if [ ! -f "$src" ]; then + echo "ERROR: declared libxml2 source missing: $src" >&2 + exit 1 fi + obj="${src%.c}.o" + # shellcheck disable=SC2086 + wasm32posix-cc $CFLAGS -c "$src" -o "$obj" + OBJS+=("$obj") done echo "==> Creating libxml2.a (${#OBJS[@]} objects)..." @@ -136,11 +205,12 @@ mkdir -p "$INSTALL_DIR/lib" "$INSTALL_DIR/include/libxml" "$INSTALL_DIR/lib/pkgc cp libxml2.a "$INSTALL_DIR/lib/" cp include/libxml/*.h "$INSTALL_DIR/include/libxml/" -# pkg-config file — write a self-contained copy that points at -# INSTALL_DIR. Consumers outside the resolver can still read Libs.private -# to pull zlib in via their own means. +# Write relocatable pkg-config metadata. The resolver supplies direct +# dependency prefixes through PKG_CONFIG_PATH, so Requires.private carries +# their search paths without baking a producer's cache directory into this +# archive. cat > "$INSTALL_DIR/lib/pkgconfig/libxml-2.0.pc" < Preserving libzip build directory: $WORK_DIR" >&2 + else + rm -rf "$WORK_DIR" + fi + exit "$status" +} +trap cleanup EXIT + +LIBZIP_VERSION="${WASM_POSIX_DEP_VERSION:-${LIBZIP_VERSION:-1.11.4}}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://libzip.org/download/libzip-${LIBZIP_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-82e9f2f2421f9d7c2466bbc3173cd09595a88ea37db0d559a9d0a2dc60dc722e}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +OUT_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/libzip-install}" +SRC_DIR="$WORK_DIR/source" +BUILD_DIR="$WORK_DIR/build" +STAGE_DIR="$WORK_DIR/stage" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: libzip currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi +if [ -z "$SOURCE_SHA256" ]; then + echo "ERROR: libzip source sha256 must not be empty" >&2 + exit 1 +fi + +# Use only the worktree-local Kandelo SDK. +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +export WASM_POSIX_SYSROOT="$SYSROOT" +for tool in cmake curl wasm-objdump; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "ERROR: required libzip build tool is unavailable: $tool" >&2 + exit 1 + fi +done +if [ ! -f "$SYSROOT/lib/libc.a" ]; then + echo "ERROR: wasm32 sysroot not found at $SYSROOT" >&2 + exit 1 +fi + +# The resolver provides every direct dependency. The fallback keeps an +# intentional direct invocation on the same resolver path. +ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:-}" +if [ -z "$ZLIB_PREFIX" ]; then + for tool in rustc cargo; do + if ! command -v "$tool" >/dev/null 2>&1; then + echo "ERROR: $tool is required when zlib is not resolver-injected" >&2 + exit 1 + fi + done + HOST_TARGET="$(rustc -vV | awk '/^host/ {print $2}')" + echo "==> Resolving zlib for $TARGET_ARCH..." + ZLIB_PREFIX="$( + cd "$REPO_ROOT" + cargo run -p xtask --target "$HOST_TARGET" --quiet -- \ + build-deps --arch "$TARGET_ARCH" resolve zlib + )" +fi +for required in \ + "$ZLIB_PREFIX/lib/libz.a" \ + "$ZLIB_PREFIX/include/zlib.h" \ + "$ZLIB_PREFIX/include/zconf.h"; do + if [ ! -f "$required" ]; then + echo "ERROR: resolved zlib output is missing: $required" >&2 + exit 1 + fi +done + +# Resolve host-side package metadata before assigning the exported CC/AR +# variables below. The dev shell exports those names, so assigning them early +# would make Cargo compile its host dependencies with the wasm SDK wrappers. +CC="$(command -v wasm32posix-cc || true)" +AR="$(command -v wasm32posix-ar || true)" +RANLIB="$(command -v wasm32posix-ranlib || true)" +NM="$(command -v wasm32posix-nm || true)" +STRIP="$(command -v wasm32posix-strip || true)" +for tool in "$CC" "$AR" "$RANLIB" "$NM" "$STRIP"; do + if [ -z "$tool" ] || ! command -v "$tool" >/dev/null 2>&1; then + echo "ERROR: required libzip SDK tool is unavailable: ${tool:-}" >&2 + exit 1 + fi +done + +echo "==> Downloading libzip $LIBZIP_VERSION..." +TARBALL="$WORK_DIR/libzip.tar.gz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors \ + -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying libzip source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +# Kandelo's executable linker deliberately permits unresolved host imports. +# That makes CMake's link-based check_function_exists probes report every +# function as present, including host-only memcpy_s/strncpy_s. Seed the +# wasm32+musl platform truth while leaving compile-only header/type probes to +# upstream CMake. +CMAKE_PLATFORM_FACTS=( + -DHAVE___PROGNAME=OFF + -DHAVE__CLOSE=OFF + -DHAVE__DUP=OFF + -DHAVE__FDOPEN=OFF + -DHAVE__FILENO=OFF + -DHAVE__FSEEKI64=OFF + -DHAVE__FSTAT64=OFF + -DHAVE__SETMODE=OFF + -DHAVE__SNPRINTF=OFF + -DHAVE__SNPRINTF_S=OFF + -DHAVE__SNWPRINTF_S=OFF + -DHAVE__STAT64=OFF + -DHAVE__STRDUP=OFF + -DHAVE__STRICMP=OFF + -DHAVE__STRTOI64=OFF + -DHAVE__STRTOUI64=OFF + -DHAVE__UNLINK=OFF + -DHAVE_ARC4RANDOM=OFF + -DHAVE_CLONEFILE=OFF + -DHAVE_EXPLICIT_BZERO=ON + -DHAVE_EXPLICIT_MEMSET=OFF + -DHAVE_FCHMOD=ON + -DHAVE_FICLONERANGE=OFF + -DHAVE_FILENO=ON + -DHAVE_FSEEKO=ON + -DHAVE_FTELLO=ON + -DHAVE_FTS_H=OFF + -DHAVE_GETPROGNAME=OFF + -DHAVE_GETSECURITYINFO=OFF + -DHAVE_LOCALTIME_R=ON + -DHAVE_LOCALTIME_S=OFF + -DHAVE_MEMCPY_S=OFF + -DHAVE_MKSTEMP=ON + -DHAVE_RANDOM=ON + -DHAVE_SETMODE=OFF + -DHAVE_SNPRINTF=ON + -DHAVE_SNPRINTF_S=OFF + -DHAVE_STRCASECMP=ON + -DHAVE_STRDUP=ON + -DHAVE_STRERROR_S=OFF + -DHAVE_STRERRORLEN_S=OFF + -DHAVE_STRICMP=OFF + -DHAVE_STRNCPY_S=OFF + -DHAVE_STRTOLL=ON + -DHAVE_STRTOULL=ON + -DHAVE_STRUCT_TM_TM_ZONE=ON + -DHAVE_DIRENT_H=ON + -DHAVE_NDIR_H=OFF + -DHAVE_SYS_DIR_H=OFF + -DHAVE_SYS_NDIR_H=OFF + -DWORDS_BIGENDIAN=OFF +) + +prefix_map_flags() { + local producer_path="$1" + local stable_path="$2" + printf '%s' "-ffile-prefix-map=$producer_path=$stable_path -fdebug-prefix-map=$producer_path=$stable_path -fmacro-prefix-map=$producer_path=$stable_path" +} + +REPRODUCIBLE_FLAGS="$(prefix_map_flags "$WORK_DIR" /usr/src/libzip-build)" +REPRODUCIBLE_FLAGS+=" $(prefix_map_flags "$REPO_ROOT" /usr/src/kandelo)" +REPRODUCIBLE_FLAGS+=" $(prefix_map_flags "$ZLIB_PREFIX" /usr/src/kandelo-deps/zlib)" + +export LC_ALL=C +export TZ=UTC +export SOURCE_DATE_EPOCH="${SOURCE_DATE_EPOCH:-0}" +export ZERO_AR_DATE=1 + +echo "==> Configuring libzip from the upstream CMake graph..." +cmake -S "$SRC_DIR" -B "$BUILD_DIR" \ + -DCMAKE_SYSTEM_NAME=Generic \ + -DCMAKE_SYSTEM_PROCESSOR=wasm32 \ + -DCMAKE_C_COMPILER="$CC" \ + -DCMAKE_AR="$AR" \ + -DCMAKE_RANLIB="$RANLIB" \ + -DCMAKE_STRIP="$STRIP" \ + -DCMAKE_BUILD_TYPE=Release \ + -DCMAKE_C_FLAGS_RELEASE="-O2 -DNDEBUG -fPIC $REPRODUCIBLE_FLAGS" \ + -DCMAKE_FIND_ROOT_PATH="$SYSROOT;$ZLIB_PREFIX" \ + -DCMAKE_FIND_ROOT_PATH_MODE_PROGRAM=NEVER \ + -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=ONLY \ + -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=ONLY \ + -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=ONLY \ + -DCMAKE_INSTALL_PREFIX="$STAGE_DIR" \ + -DCMAKE_INSTALL_LIBDIR=lib \ + -DCMAKE_INSTALL_INCLUDEDIR=include \ + -DBUILD_SHARED_LIBS=OFF \ + -DBUILD_TOOLS=OFF \ + -DBUILD_REGRESS=OFF \ + -DBUILD_OSSFUZZ=OFF \ + -DBUILD_EXAMPLES=OFF \ + -DBUILD_DOC=OFF \ + -DENABLE_BZIP2=OFF \ + -DENABLE_COMMONCRYPTO=OFF \ + -DENABLE_GNUTLS=OFF \ + -DENABLE_LZMA=OFF \ + -DENABLE_MBEDTLS=OFF \ + -DENABLE_OPENSSL=OFF \ + -DENABLE_WINDOWS_CRYPTO=OFF \ + -DENABLE_ZSTD=OFF \ + -DENABLE_FDOPEN=ON \ + -DZLIB_ROOT="$ZLIB_PREFIX" \ + -DZLIB_INCLUDE_DIR="$ZLIB_PREFIX/include" \ + -DZLIB_LIBRARY="$ZLIB_PREFIX/lib/libz.a" \ + -DZLIB_LINK_LIBRARY_NAME=z \ + "${CMAKE_PLATFORM_FACTS[@]}" + +JOBS="$(sysctl -n hw.ncpu 2>/dev/null || nproc)" +echo "==> Building and installing libzip (-j$JOBS)..." +cmake --build "$BUILD_DIR" --target install --parallel "$JOBS" + +# Publish only manifest-declared outputs. Upstream also installs CMake package +# metadata; it is intentionally excluded from this resolver package. +rm -rf "$OUT_DIR" +mkdir -p "$OUT_DIR/lib/pkgconfig" "$OUT_DIR/include" +cp "$STAGE_DIR/lib/libzip.a" "$OUT_DIR/lib/libzip.a" +cp "$STAGE_DIR/include/zip.h" "$OUT_DIR/include/zip.h" +cp "$STAGE_DIR/include/zipconf.h" "$OUT_DIR/include/zipconf.h" +# The pkg-config variable must remain literal in the generated descriptor. +# shellcheck disable=SC2016 +sed \ + -e 's|^prefix=.*|prefix=${pcfiledir}/../..|' \ + -e '/^zipcmp=/d' \ + "$STAGE_DIR/lib/pkgconfig/libzip.pc" \ + > "$OUT_DIR/lib/pkgconfig/libzip.pc" + +EXPECTED_OUTPUTS=( + lib/libzip.a + include/zip.h + include/zipconf.h + lib/pkgconfig/libzip.pc +) +for relative in "${EXPECTED_OUTPUTS[@]}"; do + if [ ! -s "$OUT_DIR/$relative" ]; then + echo "ERROR: libzip declared output is missing or empty: $relative" >&2 + exit 1 + fi +done +actual_count="$(find "$OUT_DIR" -type f | wc -l | tr -d ' ')" +if [ "$actual_count" != "${#EXPECTED_OUTPUTS[@]}" ]; then + echo "ERROR: libzip staged $actual_count files; expected only ${#EXPECTED_OUTPUTS[@]} declared outputs" >&2 + find "$OUT_DIR" -type f -print >&2 + exit 1 +fi + +symbols="$($NM "$OUT_DIR/lib/libzip.a" 2>/dev/null)" +REQUIRED_LIBZIP_SYMBOLS=( + zip_open + zip_file_set_mtime + zip_file_set_encryption + zip_libzip_version + zip_register_progress_callback_with_state + zip_register_cancel_callback_with_state + zip_compression_method_supported +) +for required_symbol in "${REQUIRED_LIBZIP_SYMBOLS[@]}"; do + if ! grep -q " T ${required_symbol}\$" <<<"$symbols"; then + echo "ERROR: libzip.a does not export $required_symbol" >&2 + exit 1 + fi +done + +# Link every archive member into a real Kandelo executable. The SDK compiler +# driver groups linker flags ahead of archive operands, so wrapping libzip.a in +# --whole-archive on this command line would not preserve their relative order. +# Extracting the archive makes every member an explicit linker input instead. +# Disable section GC as well so an unresolved reference in an otherwise-unused +# member cannot disappear before the import audit below. +SMOKE_SOURCE="$WORK_DIR/libzip-smoke.c" +SMOKE_WASM="$WORK_DIR/libzip-smoke.wasm" +SMOKE_MEMBER_DIR="$WORK_DIR/libzip-smoke-members" +cat > "$SMOKE_SOURCE" <<'EOF' +#include + +int main(void) { + int error = 0; + zip_t *archive = zip_open("/tmp/libzip-smoke.zip", ZIP_CREATE | ZIP_TRUNCATE, &error); + if (archive == NULL) { + return error == 0 ? 1 : error; + } + return zip_close(archive) == 0 ? 0 : 2; +} +EOF +mkdir -p "$SMOKE_MEMBER_DIR" +( + cd "$SMOKE_MEMBER_DIR" + "$AR" x "$OUT_DIR/lib/libzip.a" +) +archive_member_count="$($AR t "$OUT_DIR/lib/libzip.a" | wc -l | tr -d ' ')" +member_objects=() +while IFS= read -r member; do + member_objects+=("$member") +done < <(find "$SMOKE_MEMBER_DIR" -type f -print | LC_ALL=C sort) +if [ "$archive_member_count" -eq 0 ] || + [ "${#member_objects[@]}" -ne "$archive_member_count" ]; then + echo "ERROR: failed to extract every libzip archive member: archive=$archive_member_count extracted=${#member_objects[@]}" >&2 + exit 1 +fi +"$CC" -O2 \ + -I"$OUT_DIR/include" \ + "$SMOKE_SOURCE" \ + -Wl,--no-gc-sections \ + "${member_objects[@]}" \ + "$ZLIB_PREFIX/lib/libz.a" \ + -o "$SMOKE_WASM" + +# llvm-nm does not report final WebAssembly imports as undefined symbols. +# Inspect the actual import section and admit only the fixed Kandelo startup +# surface. A parser/count mismatch also fails rather than silently blessing a +# newer wasm-objdump format that this audit did not understand. +import_dump="$(wasm-objdump -x "$SMOKE_WASM")" +declared_import_count="$( + sed -n 's/^Import\[\([0-9][0-9]*\)\]:$/\1/p' <<<"$import_dump" | head -n 1 +)" +wasm_imports="$( + awk ' + /^Import\[[0-9]+\]:$/ { inside = 1; next } + inside && /^[[:alpha:]_][[:alnum:]_]*\[/ { exit } + inside && / <- / { sub(/^.* <- /, ""); print } + ' <<<"$import_dump" +)" +parsed_import_count="$( + sed '/^$/d' <<<"$wasm_imports" | wc -l | tr -d ' ' +)" +if [ -z "$declared_import_count" ] || + [ "$parsed_import_count" -ne "$declared_import_count" ]; then + echo "ERROR: could not account for every Wasm import: declared=${declared_import_count:-} parsed=$parsed_import_count" >&2 + exit 1 +fi + +ALLOWED_WASM_IMPORTS=( + env.__channel_base + env.memory + kernel.kernel_apply_fork_fd_actions + kernel.kernel_argv_read + kernel.kernel_clear_fork_exec + kernel.kernel_environ_count + kernel.kernel_environ_get + kernel.kernel_execve + kernel.kernel_exit + kernel.kernel_get_argc + kernel.kernel_get_fork_exec_argc + kernel.kernel_get_fork_exec_argv + kernel.kernel_get_fork_exec_path + kernel.kernel_is_fork_child + kernel.kernel_push_argv +) +unexpected_imports=() +while IFS= read -r wasm_import; do + [ -n "$wasm_import" ] || continue + allowed=0 + for candidate in "${ALLOWED_WASM_IMPORTS[@]}"; do + if [ "$wasm_import" = "$candidate" ]; then + allowed=1 + break + fi + done + if [ "$allowed" -eq 0 ]; then + unexpected_imports+=("$wasm_import") + fi +done <<<"$wasm_imports" +for required_import in env.__channel_base env.memory; do + if ! grep -Fxq "$required_import" <<<"$wasm_imports"; then + echo "ERROR: libzip smoke Wasm is missing required import: $required_import" >&2 + exit 1 + fi +done +if [ "${#unexpected_imports[@]}" -ne 0 ]; then + echo "ERROR: libzip smoke Wasm has unexpected imports:" >&2 + printf '%s\n' "${unexpected_imports[@]}" >&2 + exit 1 +fi +echo "==> Validated $archive_member_count libzip members and $declared_import_count Wasm imports" + +# The package must be movable between resolver cache roots. Reject producer +# checkout, temporary-build, dependency-cache, and destination paths in every +# declared output. +for forbidden in "$WORK_DIR" "$REPO_ROOT" "$ZLIB_PREFIX" "$OUT_DIR"; do + if grep -aFq "$forbidden" \ + "$OUT_DIR/lib/libzip.a" \ + "$OUT_DIR/include/zip.h" \ + "$OUT_DIR/include/zipconf.h" \ + "$OUT_DIR/lib/pkgconfig/libzip.pc"; then + echo "ERROR: libzip output contains producer path: $forbidden" >&2 + exit 1 + fi +done + +echo "==> libzip $LIBZIP_VERSION build complete" +shasum -a 256 "$OUT_DIR/lib/libzip.a" diff --git a/packages/registry/libzip/build.toml b/packages/registry/libzip/build.toml new file mode 100644 index 0000000000..e4911d54ce --- /dev/null +++ b/packages/registry/libzip/build.toml @@ -0,0 +1,10 @@ +script_path = "packages/registry/libzip/build-libzip.sh" +inputs = [ + "packages/registry/libzip/build-libzip.sh", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 1 + +[binary] +index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/libzip/package.toml b/packages/registry/libzip/package.toml new file mode 100644 index 0000000000..fae26bf6f7 --- /dev/null +++ b/packages/registry/libzip/package.toml @@ -0,0 +1,46 @@ +# Per-library manifest consumed by `cargo xtask build-deps`. +# Schema: see docs/package-management.md. + +kind = "library" + +name = "libzip" +version = "1.11.4" +kernel_abi = 7 +depends_on = ["zlib@1.3.1"] +# PHP's current extension build is wasm32-only. Do not advertise wasm64 until +# the consumer and this recipe are both validated for that architecture. +arches = ["wasm32"] + +[source] +url = "https://libzip.org/download/libzip-1.11.4.tar.gz" +sha256 = "82e9f2f2421f9d7c2466bbc3173cd09595a88ea37db0d559a9d0a2dc60dc722e" + +[license] +spdx = "BSD-3-Clause" +url = "https://github.com/nih-at/libzip/blob/v1.11.4/LICENSE" + +[build] +script_path = "packages/registry/libzip/build-libzip.sh" + +[[host_tools]] +name = "cmake" +version_constraint = ">=3.20" +probe = { args = ["--version"], version_regex = "cmake version (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "curl" +version_constraint = ">=7.0" +probe = { args = ["--version"], version_regex = "curl (\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[[host_tools]] +name = "wasm-objdump" +version_constraint = ">=1.0" +probe = { args = ["--version"], version_regex = "(\\d+\\.\\d+(?:\\.\\d+)?)" } +install_hints = { darwin = "run through scripts/dev-shell.sh", linux = "run through scripts/dev-shell.sh" } + +[outputs] +libs = ["lib/libzip.a"] +headers = ["include/zip.h", "include/zipconf.h"] +pkgconfig = ["lib/pkgconfig/libzip.pc"] diff --git a/packages/registry/mariadb-test/build.toml b/packages/registry/mariadb-test/build.toml index 8a6904e518..74818e2d6d 100644 --- a/packages/registry/mariadb-test/build.toml +++ b/packages/registry/mariadb-test/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/mariadb-test/build-mariadb-test.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 1 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/mariadb-vfs/build.toml b/packages/registry/mariadb-vfs/build.toml index 5a456a5ee6..90f99cd45f 100644 --- a/packages/registry/mariadb-vfs/build.toml +++ b/packages/registry/mariadb-vfs/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/mariadb-vfs/build-mariadb-vfs.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 2 +revision = 4 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/netcat/build-netcat.sh b/packages/registry/netcat/build-netcat.sh index 1ae2382684..378791c15d 100755 --- a/packages/registry/netcat/build-netcat.sh +++ b/packages/registry/netcat/build-netcat.sh @@ -3,15 +3,28 @@ set -euo pipefail # Build GNU Netcat 0.7.1 for wasm32-posix-kernel. -NETCAT_VERSION="${NETCAT_VERSION:-0.7.1}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -SRC_DIR="$SCRIPT_DIR/netcat-src" -BIN_DIR="$SCRIPT_DIR/bin" +NETCAT_VERSION="${WASM_POSIX_DEP_VERSION:-${NETCAT_VERSION:-0.7.1}}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://downloads.sourceforge.net/project/netcat/netcat/${NETCAT_VERSION}/netcat-${NETCAT_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-30719c9a4ffbcf15676b8f528233ccc54ee6cba96cb4590975f5fd60c68a066f}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +WORK_DIR="${WASM_POSIX_DEP_WORK_DIR:-$SCRIPT_DIR}" +SRC_DIR="$WORK_DIR/netcat-src" +BIN_DIR="$WORK_DIR/bin" SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" +# Worktree-local SDK on PATH (no global npm link required). +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: GNU Netcat is currently packaged for wasm32 only, got $TARGET_ARCH" >&2 + exit 2 +fi + if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 + echo "ERROR: wasm32posix-cc not found after sourcing sdk/activate.sh." >&2 exit 1 fi @@ -23,14 +36,26 @@ fi export WASM_POSIX_SYSROOT="$SYSROOT" export WASM_POSIX_GLUE_DIR="$REPO_ROOT/libc/glue" +SOURCE_MARKER="$SRC_DIR/.kandelo-netcat-source" +expected_source_marker="$(printf '%s\n%s\n%s' "$NETCAT_VERSION" "$SOURCE_URL" "$SOURCE_SHA256")" +if [ -d "$SRC_DIR" ] && [ "$(cat "$SOURCE_MARKER" 2>/dev/null || true)" != "$expected_source_marker" ]; then + echo "==> Existing GNU Netcat source does not match requested version/source; cleaning..." + rm -rf "$SRC_DIR" "$BIN_DIR" +fi + if [ ! -d "$SRC_DIR" ]; then echo "==> Downloading GNU Netcat $NETCAT_VERSION..." + DOWNLOAD_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-netcat-src.XXXXXX")" + trap 'rm -rf "$DOWNLOAD_DIR"' EXIT TARBALL="netcat-${NETCAT_VERSION}.tar.gz" - URL="https://downloads.sourceforge.net/project/netcat/netcat/${NETCAT_VERSION}/${TARBALL}" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$URL" -o "/tmp/$TARBALL" + curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$DOWNLOAD_DIR/$TARBALL" + echo "==> Verifying source sha256..." + echo "$SOURCE_SHA256 $DOWNLOAD_DIR/$TARBALL" | shasum -a 256 -c - mkdir -p "$SRC_DIR" - tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 - rm "/tmp/$TARBALL" + tar xzf "$DOWNLOAD_DIR/$TARBALL" -C "$SRC_DIR" --strip-components=1 + printf '%s\n' "$expected_source_marker" > "$SOURCE_MARKER" + trap - EXIT + rm -rf "$DOWNLOAD_DIR" fi cd "$SRC_DIR" @@ -40,6 +65,7 @@ PATCH_SET=( "listen-success-exit.patch" "udp-listen-single-socket.patch" "disable-pktinfo.patch" + "disable-abortive-linger.patch" ) echo "==> Verifying Kandelo netcat portability patches..." for patch_name in "${PATCH_SET[@]}"; do @@ -76,6 +102,11 @@ if ! grep -q "/\\* # define USE_PKTINFO \\*/" src/netcat.h; then exit 1 fi +if ! grep -q "Kandelo cannot yet model abortive SO_LINGER" src/network.c; then + echo "ERROR: disable-abortive-linger.patch is missing from src/network.c" >&2 + exit 1 +fi + if [ ! -f Makefile ]; then echo "==> Configuring GNU Netcat for wasm32..." export ac_cv_func_malloc_0_nonnull=yes @@ -108,13 +139,32 @@ fi echo "==> Applying fork instrumentation metadata..." FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" -"$FORK_INSTRUMENT" "$NETCAT_BIN" -o "$NETCAT_BIN.instr" +(cd "$REPO_ROOT" && "$FORK_INSTRUMENT" "$NETCAT_BIN" -o "$NETCAT_BIN.instr") mv "$NETCAT_BIN.instr" "$NETCAT_BIN" mkdir -p "$BIN_DIR" cp "$NETCAT_BIN" "$BIN_DIR/nc.wasm" -source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary netcat "$BIN_DIR/nc.wasm" +if [ -n "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + # Resolver builds must publish only into the resolver-owned output + # directory. Apply the same artifact guards as install_local_binary without + # writing a local-binaries override into the source worktree. + # shellcheck source=/dev/null + source "$REPO_ROOT/scripts/wasm-artifact-guards.sh" + if ! wasm_is_binary "$BIN_DIR/nc.wasm"; then + echo "ERROR: refusing non-Wasm netcat artifact: $BIN_DIR/nc.wasm" >&2 + exit 1 + fi + wasm_require_no_legacy_asyncify "$BIN_DIR/nc.wasm" + wasm_require_fork_instrumentation_if_needed "$BIN_DIR/nc.wasm" + mkdir -p "$WASM_POSIX_DEP_OUT_DIR" + cp "$BIN_DIR/nc.wasm" "$WASM_POSIX_DEP_OUT_DIR/nc.wasm" + echo " installed $WASM_POSIX_DEP_OUT_DIR/nc.wasm (resolver scratch)" +else + # Direct developer builds retain the normal local resolver override. + # shellcheck source=/dev/null + source "$REPO_ROOT/scripts/install-local-binary.sh" + install_local_binary netcat "$BIN_DIR/nc.wasm" +fi ls -lh "$BIN_DIR/nc.wasm" diff --git a/packages/registry/netcat/build.toml b/packages/registry/netcat/build.toml index e19e4a7fa0..e910c2d7c7 100644 --- a/packages/registry/netcat/build.toml +++ b/packages/registry/netcat/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/netcat/build-netcat.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "2e6293a50ccf996b1a434aa701057b862a46c587" -revision = 1 +revision = 2 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/netcat/patches/disable-abortive-linger.patch b/packages/registry/netcat/patches/disable-abortive-linger.patch new file mode 100644 index 0000000000..855af9bfe7 --- /dev/null +++ b/packages/registry/netcat/patches/disable-abortive-linger.patch @@ -0,0 +1,33 @@ +diff --git a/src/network.c b/src/network.c +index 23b2d2c..62e1f51 100644 +--- a/src/network.c ++++ b/src/network.c +@@ -351,6 +351,8 @@ int netcat_socket_new(int domain, int type) + { + int sock, ret, sockopt; ++#if !defined(__wasm32__) + struct linger fix_ling; ++#endif + + sock = socket(domain, type, 0); + if (sock < 0) +@@ -359,5 +361,11 @@ int netcat_socket_new(int domain, int type) + + /* don't leave the socket in a TIME_WAIT state if we close the connection */ ++#if defined(__wasm32__) ++ /* Kandelo cannot yet model abortive SO_LINGER coherently across its ++ in-kernel, Node, and browser transports. The platform truthfully rejects ++ the enabled option, so retain ordinary FIN close semantics here. */ ++ ret = 0; ++#else + fix_ling.l_onoff = 1; + fix_ling.l_linger = 0; + ret = setsockopt(sock, SOL_SOCKET, SO_LINGER, &fix_ling, sizeof(fix_ling)); +@@ -367,6 +375,7 @@ int netcat_socket_new(int domain, int type) + close(sock); /* anyway the socket was created */ + return -2; + } ++#endif + + /* fix the socket options */ + sockopt = 1; diff --git a/packages/registry/node-vfs/build.toml b/packages/registry/node-vfs/build.toml index b7d7d96551..8df934459d 100644 --- a/packages/registry/node-vfs/build.toml +++ b/packages/registry/node-vfs/build.toml @@ -21,7 +21,7 @@ inputs = [ ] repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 7 +revision = 8 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/openssl/build-openssl.sh b/packages/registry/openssl/build-openssl.sh index e26e729871..aa5dc6a1bf 100755 --- a/packages/registry/openssl/build-openssl.sh +++ b/packages/registry/openssl/build-openssl.sh @@ -1,56 +1,63 @@ #!/usr/bin/env bash -# -# Build OpenSSL static libs (libssl.a, libcrypto.a) for -# wasm32-posix-kernel. -# -# Honors the dep-resolver build-script contract (see -# docs/dependency-management.md). Falls back to the in-tree -# openssl-install/ layout when invoked without resolver env vars. +# Build OpenSSL static libraries with stable guest paths and exact outputs. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC_DIR="$SCRIPT_DIR/openssl-src" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-openssl.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT +SRC_DIR="$WORK_DIR/source" +STAGE_DIR="$WORK_DIR/stage" + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" -# --- Resolver contract (with legacy fallbacks) --- OPENSSL_VERSION="${WASM_POSIX_DEP_VERSION:-${OPENSSL_VERSION:-3.3.2}}" INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/openssl-install}" SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/openssl/openssl/releases/download/openssl-${OPENSSL_VERSION}/openssl-${OPENSSL_VERSION}.tar.gz}" -SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" - -if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 - exit 1 -fi - -# --- Fetch + verify source --- -if [ ! -d "$SRC_DIR" ]; then - echo "==> Downloading OpenSSL $OPENSSL_VERSION..." - TARBALL="openssl-${OPENSSL_VERSION}.tar.gz" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "/tmp/${TARBALL}" - if [ -n "$SOURCE_SHA256" ]; then - echo "==> Verifying source sha256..." - echo "$SOURCE_SHA256 /tmp/${TARBALL}" | shasum -a 256 -c - - fi - mkdir -p "$SRC_DIR" - tar xzf "/tmp/${TARBALL}" -C "$SRC_DIR" --strip-components=1 - rm "/tmp/${TARBALL}" -fi +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-2e8a40b01979afe8be0bbfb3de5dc1c6709fedb46d6c89c10da114ab5fc3d281}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" -cd "$SRC_DIR" +case "$TARGET_ARCH" in + wasm32) + CONFIGURE_TARGET=linux-generic32 + SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" + ;; + wasm64) + CONFIGURE_TARGET=linux-generic64 + SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot64}" + ;; + *) + echo "ERROR: OpenSSL supports wasm32 and wasm64, got $TARGET_ARCH" >&2 + exit 1 + ;; +esac +export WASM_POSIX_SYSROOT="$SYSROOT" -# Clean previous build so a cache-miss rebuild starts from scratch. -if [ -f Makefile ]; then - make clean 2>/dev/null || true -fi -rm -rf "$INSTALL_DIR" +CC="${TARGET_ARCH}posix-cc" +AR="${TARGET_ARCH}posix-ar" +RANLIB="${TARGET_ARCH}posix-ranlib" +for tool in "$CC" "$AR" "$RANLIB"; do + command -v "$tool" >/dev/null || { + echo "ERROR: $tool not found after sourcing sdk/activate.sh" >&2 + exit 1 + } +done + +echo "==> Downloading OpenSSL $OPENSSL_VERSION..." +TARBALL="$WORK_DIR/openssl.tar.gz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 -# Configure for Wasm using linux-generic32 target. -echo "==> Configuring OpenSSL for Wasm..." -CC=wasm32posix-cc \ -AR=wasm32posix-ar \ -RANLIB=wasm32posix-ranlib \ -perl Configure linux-generic32 \ +cd "$SRC_DIR" +echo "==> Configuring OpenSSL for $TARGET_ARCH..." +CC="$CC" AR="$AR" RANLIB="$RANLIB" \ +CFLAGS="-O2" \ +perl Configure "$CONFIGURE_TARGET" \ -DHAVE_FORK=0 \ -DOPENSSL_NO_AFALGENG=1 \ -DOPENSSL_NO_UI_CONSOLE=1 \ @@ -65,44 +72,73 @@ perl Configure linux-generic32 \ no-ui-console \ no-tests \ no-apps \ - no-autoerrinit \ no-posix-io \ - --prefix="$INSTALL_DIR" \ - --openssldir=/etc/ssl - -# Patch Makefile: remove cross-compile prefix + the -m32 that -# linux-generic32 assumes. -echo "==> Patching Makefile..." -sed -i.bak 's/^CROSS_COMPILE=.*/CROSS_COMPILE=/' Makefile -sed -i.bak 's/ -m32 / /g' Makefile -sed -i.bak 's/ -m32$//' Makefile + --prefix=/usr \ + --openssldir=/etc/ssl \ + --libdir=lib + +# linux-generic targets assume a native compiler driver. Keep their integer +# model but remove host-only compiler switches and any cross prefix. +sed -i.bak \ + -e 's/^CROSS_COMPILE=.*/CROSS_COMPILE=/' \ + -e 's/ -m32 / /g' \ + -e 's/ -m32$//' \ + -e 's/ -m64 / /g' \ + -e 's/ -m64$//' \ + Makefile rm -f Makefile.bak echo "==> Building OpenSSL..." make -j"$(nproc 2>/dev/null || sysctl -n hw.ncpu)" build_generated libssl.a libcrypto.a -echo "==> Installing..." -make install_sw 2>/dev/null || true - -# OpenSSL installs into lib/ on 32-bit targets and lib64/ on some -# 64-bit Linux hosts. The resolver contract pins outputs under lib/, -# so if install landed in lib64/ we merge it across. -if [ -d "$INSTALL_DIR/lib64" ] && [ ! -d "$INSTALL_DIR/lib" ]; then - mv "$INSTALL_DIR/lib64" "$INSTALL_DIR/lib" -elif [ -d "$INSTALL_DIR/lib64" ]; then - # Both exist — splice lib64's contents into lib/. - cp -a "$INSTALL_DIR/lib64/." "$INSTALL_DIR/lib/" - rm -rf "$INSTALL_DIR/lib64" -fi - -if [ -f "$INSTALL_DIR/lib/libssl.a" ] && [ -f "$INSTALL_DIR/lib/libcrypto.a" ]; then - echo "==> OpenSSL build complete!" - echo " Headers: $INSTALL_DIR/include/openssl/" - echo " libssl: $INSTALL_DIR/lib/libssl.a" - echo " libcrypto: $INSTALL_DIR/lib/libcrypto.a" - ls -lh "$INSTALL_DIR/lib/libssl.a" "$INSTALL_DIR/lib/libcrypto.a" -else - echo "ERROR: Build failed — libraries not found at expected paths" >&2 - find "$INSTALL_DIR" -name "*.a" 2>/dev/null || true - exit 1 -fi +echo "==> Staging OpenSSL development files..." +make install_dev DESTDIR="$STAGE_DIR" +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR/lib/pkgconfig" "$INSTALL_DIR/include" +cp "$STAGE_DIR/usr/lib/libssl.a" "$INSTALL_DIR/lib/" +cp "$STAGE_DIR/usr/lib/libcrypto.a" "$INSTALL_DIR/lib/" +cp -R "$STAGE_DIR/usr/include/openssl" "$INSTALL_DIR/include/" + +cat > "$INSTALL_DIR/lib/pkgconfig/libcrypto.pc" < "$INSTALL_DIR/lib/pkgconfig/libssl.pc" < "$INSTALL_DIR/lib/pkgconfig/openssl.pc" < OpenSSL build complete!" +ls -lh "$INSTALL_DIR/lib/libssl.a" "$INSTALL_DIR/lib/libcrypto.a" diff --git a/packages/registry/openssl/build.toml b/packages/registry/openssl/build.toml index d36991dad2..c5c4a4a307 100644 --- a/packages/registry/openssl/build.toml +++ b/packages/registry/openssl/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/openssl/build-openssl.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 1 +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/openssl/package.toml b/packages/registry/openssl/package.toml index 37f0fb692e..496c1f82c6 100644 --- a/packages/registry/openssl/package.toml +++ b/packages/registry/openssl/package.toml @@ -6,8 +6,8 @@ name = "openssl" version = "3.3.2" kernel_abi = 7 depends_on = [] -# Opt into wasm64 because PHP depends on it. See -# memory/wasm64-build-policy.md. +# OpenSSL is one of the low-level packages that opts into both target arches; +# see the `arches` policy in docs/package-management.md. arches = ["wasm32", "wasm64"] [source] @@ -31,4 +31,4 @@ libs = ["lib/libssl.a", "lib/libcrypto.a"] # and listing every header individually would be churn on every version # bump. headers = ["include/openssl"] -pkgconfig = ["lib/pkgconfig/libssl.pc", "lib/pkgconfig/libcrypto.pc"] +pkgconfig = ["lib/pkgconfig/libssl.pc", "lib/pkgconfig/libcrypto.pc", "lib/pkgconfig/openssl.pc"] diff --git a/packages/registry/perl-vfs/build.toml b/packages/registry/perl-vfs/build.toml index ea96416dc6..539cae8c7a 100644 --- a/packages/registry/perl-vfs/build.toml +++ b/packages/registry/perl-vfs/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/perl-vfs/build-perl-vfs.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 2 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/php/build-php.sh b/packages/registry/php/build-php.sh index 0407f915a2..04dd174296 100755 --- a/packages/registry/php/build-php.sh +++ b/packages/registry/php/build-php.sh @@ -15,12 +15,33 @@ set -euo pipefail # CFLAGS/LDFLAGS are set to FPM's stricter requirements. CLI ships # with the same flags for debuggability. -PHP_VERSION="${PHP_VERSION:-8.3.15}" SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC_DIR="$SCRIPT_DIR/php-src" -INSTALL_DIR="$SCRIPT_DIR/php-install" - REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +PHP_VERSION="${WASM_POSIX_DEP_VERSION:-${PHP_VERSION:-8.3.15}}" +SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://www.php.net/distributions/php-${PHP_VERSION}.tar.gz}" +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-67073c3c9c56c86461e0715d9e1806af5ddffe8e6e2eb9781f7923bbb5bd67fa}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-php.XXXXXX")" +cleanup() { + status=$? + trap - EXIT + if [ "${WASM_POSIX_KEEP_BUILD_DIR:-0}" = "1" ]; then + echo "==> Preserving PHP build directory: $WORK_DIR" >&2 + else + rm -rf "$WORK_DIR" + fi + exit "$status" +} +trap cleanup EXIT +SRC_DIR="$WORK_DIR/source" +BIN_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/bin}" +CONFIG_CACHE="$WORK_DIR/config.cache" +GUEST_PREFIX="/usr" + +if [ "$TARGET_ARCH" != "wasm32" ]; then + echo "ERROR: PHP currently supports only wasm32, got $TARGET_ARCH" >&2 + exit 1 +fi # Worktree-local SDK on PATH (no global npm link required). # shellcheck source=/dev/null source "$REPO_ROOT/sdk/activate.sh" @@ -37,7 +58,7 @@ export WASM_POSIX_SYSROOT="$SYSROOT" HOST_TARGET="$(rustc -vV | awk '/^host/ {print $2}')" resolve_dep() { local name="$1" - (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TARGET" --quiet -- build-deps resolve "$name") + (cd "$REPO_ROOT" && cargo run -p xtask --target "$HOST_TARGET" --quiet -- build-deps --arch "$TARGET_ARCH" resolve "$name") } ZLIB_PREFIX="${WASM_POSIX_DEP_ZLIB_DIR:-}" @@ -48,37 +69,148 @@ OPENSSL_PREFIX="${WASM_POSIX_DEP_OPENSSL_DIR:-}" [ -z "$OPENSSL_PREFIX" ] && { echo "==> Resolving openssl..."; OPENSSL_PREFIX="$(resolve_dep openssl)"; } LIBXML2_PREFIX="${WASM_POSIX_DEP_LIBXML2_DIR:-}" [ -z "$LIBXML2_PREFIX" ] && { echo "==> Resolving libxml2..."; LIBXML2_PREFIX="$(resolve_dep libxml2)"; } +LIBICONV_PREFIX="${WASM_POSIX_DEP_LIBICONV_DIR:-}" +[ -z "$LIBICONV_PREFIX" ] && { echo "==> Resolving GNU libiconv..."; LIBICONV_PREFIX="$(resolve_dep libiconv)"; } +LIBZIP_PREFIX="${WASM_POSIX_DEP_LIBZIP_DIR:-}" +[ -z "$LIBZIP_PREFIX" ] && { echo "==> Resolving libzip..."; LIBZIP_PREFIX="$(resolve_dep libzip)"; } +LIBCURL_PREFIX="${WASM_POSIX_DEP_LIBCURL_DIR:-}" +[ -z "$LIBCURL_PREFIX" ] && { echo "==> Resolving libcurl..."; LIBCURL_PREFIX="$(resolve_dep libcurl)"; } +# ICU and libcxx back only the runtime-loadable intl side module. Keep them as +# explicit direct dependencies so the recipe never succeeds because a prior +# local build happened to leave C++ headers or ICU archives in the sysroot. +ICU_PREFIX="${WASM_POSIX_DEP_ICU_DIR:-}" +[ -z "$ICU_PREFIX" ] && { echo "==> Resolving icu..."; ICU_PREFIX="$(resolve_dep icu)"; } +LIBCXX_PREFIX="${WASM_POSIX_DEP_LIBCXX_DIR:-}" +[ -z "$LIBCXX_PREFIX" ] && { echo "==> Resolving libcxx..."; LIBCXX_PREFIX="$(resolve_dep libcxx)"; } [ -f "$ZLIB_PREFIX/lib/libz.a" ] || { echo "ERROR: zlib resolve missing libz.a"; exit 1; } [ -f "$SQLITE_PREFIX/lib/libsqlite3.a" ] || { echo "ERROR: sqlite resolve missing libsqlite3.a"; exit 1; } [ -f "$OPENSSL_PREFIX/lib/libssl.a" ] || { echo "ERROR: openssl resolve missing libssl.a"; exit 1; } [ -f "$LIBXML2_PREFIX/lib/libxml2.a" ] || { echo "ERROR: libxml2 resolve missing libxml2.a"; exit 1; } +[ -f "$LIBICONV_PREFIX/lib/libiconv.a" ] || { echo "ERROR: GNU libiconv resolve missing libiconv.a"; exit 1; } +[ -f "$LIBZIP_PREFIX/lib/libzip.a" ] || { echo "ERROR: libzip resolve missing libzip.a"; exit 1; } +[ -f "$LIBCURL_PREFIX/lib/libcurl.a" ] || { echo "ERROR: libcurl resolve missing libcurl.a"; exit 1; } +[ -f "$ICU_PREFIX/lib/libicuuc.a" ] || { echo "ERROR: icu resolve missing libicuuc.a"; exit 1; } +[ -f "$ICU_PREFIX/lib/libicui18n.a" ] || { echo "ERROR: icu resolve missing libicui18n.a"; exit 1; } +[ -f "$ICU_PREFIX/lib/libicuio.a" ] || { echo "ERROR: icu resolve missing libicuio.a"; exit 1; } +[ -f "$ICU_PREFIX/lib/libicudata.a" ] || { echo "ERROR: icu resolve missing libicudata.a"; exit 1; } +[ -f "$ICU_PREFIX/share/icu.dat" ] || { echo "ERROR: icu resolve missing share/icu.dat"; exit 1; } +[ -f "$LIBCXX_PREFIX/lib/libc++.a" ] || { echo "ERROR: libcxx resolve missing libc++.a"; exit 1; } +[ -f "$LIBCXX_PREFIX/lib/libc++abi.a" ] || { echo "ERROR: libcxx resolve missing libc++abi.a"; exit 1; } +[ -f "$LIBCXX_PREFIX/lib/libc++-pic.a" ] || { echo "ERROR: libcxx resolve missing libc++-pic.a"; exit 1; } +[ -f "$LIBCXX_PREFIX/lib/libc++abi-pic.a" ] || { echo "ERROR: libcxx resolve missing libc++abi-pic.a"; exit 1; } +[ -d "$LIBCXX_PREFIX/include/c++/v1" ] || { echo "ERROR: libcxx resolve missing include/c++/v1"; exit 1; } echo "==> zlib at $ZLIB_PREFIX" echo "==> sqlite at $SQLITE_PREFIX" echo "==> openssl at $OPENSSL_PREFIX" echo "==> libxml2 at $LIBXML2_PREFIX" +echo "==> GNU libiconv at $LIBICONV_PREFIX" +echo "==> libzip at $LIBZIP_PREFIX" +echo "==> libcurl at $LIBCURL_PREFIX" +echo "==> icu at $ICU_PREFIX" +echo "==> libcxx at $LIBCXX_PREFIX" + +# The SDK's C++ driver reads libc++ from the worktree-local sysroot. Point that +# declared toolchain location at the resolver-owned dependency, matching the +# existing MariaDB/SpiderMonkey package contract. The intl side-module link +# below names the PIC archives explicitly; these non-PIC names are only for +# configure probes and any main-module C++ checks. +mkdir -p "$SYSROOT/lib" "$SYSROOT/include/c++" +ln -sf "$LIBCXX_PREFIX/lib/libc++.a" "$SYSROOT/lib/libc++.a" +ln -sf "$LIBCXX_PREFIX/lib/libc++abi.a" "$SYSROOT/lib/libc++abi.a" +ln -sf "$LIBCXX_PREFIX/lib/libc++.a" "$SYSROOT/lib/libstdc++.a" +rm -rf "$SYSROOT/include/c++/v1" +ln -sfn "$LIBCXX_PREFIX/include/c++/v1" "$SYSROOT/include/c++/v1" -# Compose PKG_CONFIG_PATH for all 4 deps so wasm32posix-configure's +# Compose PKG_CONFIG_PATH for all deps so wasm32posix-configure's # pkg-config probes can find them in the cache instead of the sysroot. -DEP_PKG_CONFIG_PATH="$ZLIB_PREFIX/lib/pkgconfig:$SQLITE_PREFIX/lib/pkgconfig:$OPENSSL_PREFIX/lib/pkgconfig:$LIBXML2_PREFIX/lib/pkgconfig" +DEP_PKG_CONFIG_PATH="$ZLIB_PREFIX/lib/pkgconfig:$SQLITE_PREFIX/lib/pkgconfig:$OPENSSL_PREFIX/lib/pkgconfig:$LIBXML2_PREFIX/lib/pkgconfig:$LIBICONV_PREFIX/lib/pkgconfig:$LIBZIP_PREFIX/lib/pkgconfig:$LIBCURL_PREFIX/lib/pkgconfig:$ICU_PREFIX/lib/pkgconfig" # Compose -I and -L flags for defense-in-depth (autoconf raw probes). -DEP_CPPFLAGS="-I$ZLIB_PREFIX/include -I$SQLITE_PREFIX/include -I$OPENSSL_PREFIX/include -I$LIBXML2_PREFIX/include" -DEP_LDFLAGS="-L$ZLIB_PREFIX/lib -L$SQLITE_PREFIX/lib -L$OPENSSL_PREFIX/lib -L$LIBXML2_PREFIX/lib" - -if [ ! -d "$SRC_DIR" ]; then - echo "==> Downloading PHP $PHP_VERSION..." - TARBALL="php-${PHP_VERSION}.tar.gz" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "https://www.php.net/distributions/${TARBALL}" -o "/tmp/${TARBALL}" - mkdir -p "$SRC_DIR" - tar xzf "/tmp/${TARBALL}" -C "$SRC_DIR" --strip-components=1 - rm "/tmp/${TARBALL}" -fi +DEP_CPPFLAGS="-I$ZLIB_PREFIX/include -I$SQLITE_PREFIX/include -I$OPENSSL_PREFIX/include -I$LIBXML2_PREFIX/include -I$LIBICONV_PREFIX/include -I$LIBZIP_PREFIX/include -I$LIBCURL_PREFIX/include" +DEP_LDFLAGS="-L$ZLIB_PREFIX/lib -L$SQLITE_PREFIX/lib -L$OPENSSL_PREFIX/lib -L$LIBXML2_PREFIX/lib -L$LIBICONV_PREFIX/lib -L$LIBZIP_PREFIX/lib -L$LIBCURL_PREFIX/lib" + +# Some locally rebuilt dependency prefixes used during PHP/kernel +# conformance iteration intentionally contain only headers and static +# archives, not pkg-config metadata. PHP's configure probes support explicit +# _CFLAGS/_LIBS overrides, so provide them unconditionally. This +# keeps the package build independent of host pkg-config installation details +# while still using the normal PHP dependency-discovery path. +ZLIB_CFLAGS_VALUE="-I$ZLIB_PREFIX/include" +ZLIB_LIBS_VALUE="-L$ZLIB_PREFIX/lib -lz" +SQLITE_CFLAGS_VALUE="-I$SQLITE_PREFIX/include" +SQLITE_LIBS_VALUE="-L$SQLITE_PREFIX/lib -lsqlite3" +OPENSSL_CFLAGS_VALUE="-I$OPENSSL_PREFIX/include" +OPENSSL_LIBS_VALUE="-L$OPENSSL_PREFIX/lib -lssl -lcrypto" +LIBXML_CFLAGS_VALUE="-I$LIBXML2_PREFIX/include/libxml2 -I$LIBXML2_PREFIX/include" +LIBXML_LIBS_VALUE="-L$LIBXML2_PREFIX/lib -lxml2 -L$LIBICONV_PREFIX/lib -liconv -lcharset -lz" +ICONV_CFLAGS_VALUE="-I$LIBICONV_PREFIX/include" +ICONV_LIBS_VALUE="-L$LIBICONV_PREFIX/lib -liconv -lcharset" +LIBZIP_CFLAGS_VALUE="-I$LIBZIP_PREFIX/include" +LIBZIP_LIBS_VALUE="-L$LIBZIP_PREFIX/lib -lzip -L$ZLIB_PREFIX/lib -lz" +CURL_CFLAGS_VALUE="-I$LIBCURL_PREFIX/include -DCURL_STATICLIB" +CURL_LIBS_VALUE="-L$LIBCURL_PREFIX/lib -lcurl" + +prefix_map_flags() { + local producer_path="$1" + local stable_path="$2" + printf '%s' "-ffile-prefix-map=$producer_path=$stable_path -fdebug-prefix-map=$producer_path=$stable_path -fmacro-prefix-map=$producer_path=$stable_path" +} + +# Shared modules retain line-table directory entries after linking, unlike the +# optimized CLI/FPM binaries. Map every producer-controlled absolute prefix so +# package artifacts do not encode the checkout, cache, or temporary build path. +REPRODUCIBLE_PREFIX_MAPS="$(prefix_map_flags "$WORK_DIR" /usr/src/php-build)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$REPO_ROOT" /usr/src/kandelo)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$ZLIB_PREFIX" /usr/src/kandelo-deps/zlib)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$SQLITE_PREFIX" /usr/src/kandelo-deps/sqlite)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$OPENSSL_PREFIX" /usr/src/kandelo-deps/openssl)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$LIBXML2_PREFIX" /usr/src/kandelo-deps/libxml2)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$LIBICONV_PREFIX" /usr/src/kandelo-deps/libiconv)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$LIBZIP_PREFIX" /usr/src/kandelo-deps/libzip)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$LIBCURL_PREFIX" /usr/src/kandelo-deps/libcurl)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$ICU_PREFIX" /usr/src/kandelo-deps/icu)" +REPRODUCIBLE_PREFIX_MAPS+=" $(prefix_map_flags "$LIBCXX_PREFIX" /usr/src/kandelo-deps/libcxx)" + +echo "==> Downloading PHP $PHP_VERSION..." +TARBALL="$WORK_DIR/php.tar.gz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 + +rm -rf "$BIN_DIR" +mkdir -p "$BIN_DIR" cd "$SRC_DIR" # Apply patches for Wasm compatibility echo "==> Patching PHP for Wasm..." +# The upstream no-phar test fixture is a self-extracting PHP stub embedded at +# fixed byte offsets. It masks CRC values with the literal 0xffffffff; on +# wasm32/PHP with 32-bit longs that literal is a float and PHP 8.3 emits +# E_DEPRECATED before the fixture output. Use an equal-width integer mask so +# the fixture remains byte-offset-compatible and works on both 32-bit and +# 64-bit PHP runtimes. The fixture is also a signed phar archive used by other +# tests, so refresh its SHA1 phar signature after changing the stub bytes. +if [ -f ext/phar/tests/files/nophar.phar ] \ + && grep -aq '0xffffffff' ext/phar/tests/files/nophar.phar; then + python3 - <<'PY' +from pathlib import Path +import hashlib +p = Path("ext/phar/tests/files/nophar.phar") +b = bytearray(p.read_bytes().replace(b"0xffffffff", b"(-1) ")) +if b[-4:] != b"GBMB": + raise SystemExit("nophar.phar: missing phar signature magic") +algo = int.from_bytes(b[-8:-4], "little") +if algo != 2: + raise SystemExit(f"nophar.phar: expected SHA1 signature algorithm 2, got {algo}") +b[-28:-8] = hashlib.sha1(bytes(b[:-28])).digest() +p.write_bytes(b) +PY +fi + # Disable inline assembly in Zend (safety net — Wasm doesn't match arch guards anyway) if ! grep -q 'ZEND_USE_ASM_ARITHMETIC 0' Zend/zend_multiply.h 2>/dev/null; then if [ -f Zend/zend_multiply.h ]; then @@ -88,55 +220,599 @@ if ! grep -q 'ZEND_USE_ASM_ARITHMETIC 0' Zend/zend_multiply.h 2>/dev/null; then fi fi -# opcache's MAP_ANON shared-memory probe is an AC_RUN_IFELSE that fails -# under cross-compilation; the fallback only sets have_shm_mmap_anon=yes -# for *linux* hosts (configure ext/opcache/config.m4). Without this -# patch, configure rejects --enable-opcache with "No supported shared -# memory caching support". For our wasm target the runtime semantics -# are fine: each php-fpm worker is its own wasm instance, so an -# MAP_SHARED|MAP_ANON allocation is naturally per-process — exactly the -# per-worker opcache the user wants. Flip the cross-compile fallback's -# *) branch from "no" to "yes" so opcache builds. The pattern -# " have_shm_mmap_anon=no" followed by " ;;" appears only in -# that fallback (the AC_RUN_IFELSE failure branch is one-line: -# `e) have_shm_mmap_anon=no ;;`). +# When ZEND_MAX_EXECUTION_TIMERS is enabled, zend_executor_globals embeds a +# `struct sigaction`. Some translation units include zend_globals.h without +# having included first, leaving that struct incomplete. Include the +# standard timer/signal declarations at the header that owns those fields. +if [ -f Zend/zend_globals.h ] \ + && ! grep -q "wasm-zend-max-execution-timers include patch applied" Zend/zend_globals.h; then + sed -i.bak '/#include /a\ +#ifdef ZEND_MAX_EXECUTION_TIMERS\ +# include \ +# include \ +#endif\ +/* wasm-zend-max-execution-timers include patch applied */' Zend/zend_globals.h + rm -f Zend/zend_globals.h.bak +fi + +# The Wasm timeout path below is driven by a host-side cooperative timer. PHP's +# native implementation requires Linux SIGEV_THREAD_ID delivery, which Kandelo +# does not implement, so keep the native entry points as no-ops and make the +# cooperative hook the sole owner of Wasm max-execution timers. +if [ -f Zend/zend_max_execution_timer.c ] \ + && ! grep -q "wasm native max-execution timer disabled" Zend/zend_max_execution_timer.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("Zend/zend_max_execution_timer.c") +s = p.read_text() +opening = "#ifdef ZEND_MAX_EXECUTION_TIMERS\n\n" +replacement = ( + opening + + "#if defined(__wasm32__) || defined(__wasm64__)\n\n" + + "#include \"zend.h\"\n\n" + + "/* wasm native max-execution timer disabled; zend_execute_API.c owns the host hook */\n" + + "ZEND_API void zend_max_execution_timer_init(void) {}\n" + + "void zend_max_execution_timer_settime(zend_long seconds) { (void) seconds; }\n" + + "void zend_max_execution_timer_shutdown(void) {}\n\n" + + "#else\n\n" +) +if s.count(opening) != 1: + raise SystemExit("Zend timer patch: expected one ZEND_MAX_EXECUTION_TIMERS guard") +s = s.replace(opening, replacement, 1) +closing = "\n#endif\n" +index = s.rfind(closing) +if index < 0: + raise SystemExit("Zend timer patch: final guard not found") +s = s[:index] + "\n#endif /* wasm native timer */" + s[index:] +p.write_text(s) +PY +fi + +# WebAssembly cannot receive native async POSIX signals while a process worker +# is executing a CPU-bound Wasm loop. PHP's VM already cooperatively checks +# EG(vm_interrupt) on loop backedges; provide a wasm-posix timer hook that the +# host runs from the kernel worker and uses to set EG(timed_out)+EG(vm_interrupt) +# in shared process memory. This preserves PHP's general max_execution_time +# behavior on wasm without special-casing the kernel for PHP. +if [ -f Zend/zend_execute_API.c ] \ + && ! grep -q "wasm-vm-interrupt-timer patch applied" Zend/zend_execute_API.c; then + python3 - <<'PY' +from pathlib import Path +p = Path("Zend/zend_execute_API.c") +s = p.read_text() +s = s.replace( + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n", + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n" + "#if defined(__wasm32__) || defined(__wasm64__)\n" + "extern void __wasm_posix_vm_interrupt_after(void *timed_out, void *vm_interrupt, zend_long seconds);\n" + "#endif\n" + "/* wasm-vm-interrupt-timer patch applied */\n", +) +s = s.replace( + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "\tzend_max_execution_timer_settime(seconds);\n", + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * Schedule the cooperative Wasm VM interrupt only for the normal\n" + "\t * timeout phase (or for seconds=0 cancellation). PHP's native\n" + "\t * ZEND_MAX_EXECUTION_TIMERS path sets EG(timed_out) before arming\n" + "\t * hard_timeout, and that hard timeout must continue to be enforced\n" + "\t * by the POSIX timer signal path so PHP reports n+hard seconds and\n" + "\t * exits like a normal POSIX build.\n" + "\t */\n" + "\tif (seconds <= 0 || !zend_atomic_bool_load_ex(&EG(timed_out))) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t}\n" + "# endif\n" + "\tzend_max_execution_timer_settime(seconds);\n", +) +s = s.replace( + "#else\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", + "#else\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * When the cooperative Wasm VM interrupt observes the soft timeout\n" + "\t * before the POSIX signal is delivered, the disarm above prevents PHP's\n" + "\t * native signal handler from arming hard_timeout. Re-arm the cooperative\n" + "\t * interrupt for the shutdown hard-timeout window so runaway shutdown\n" + "\t * handlers terminate like they do on a POSIX build.\n" + "\t */\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), EG(hard_timeout));\n" + "\t\tEG(hard_timeout) = 0;\n" + "\t}\n" + "# endif\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", +) +p.write_text(s) +PY +fi + +# The cooperative VM interrupt above preserves normal max_execution_time +# behavior, but it cannot asynchronously interrupt a CPU-bound Wasm function. +# If PHP first regains control after both max_execution_time and hard_timeout +# elapsed, report the native hard-timeout diagnostic that upstream emits from +# its signal handler. +if [ -f Zend/zend_execute_API.c ] \ + && ! grep -q "wasm-vm-interrupt-hard-timeout patch applied" Zend/zend_execute_API.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("Zend/zend_execute_API.c") +s = p.read_text() + +def replace_once(old: str, new: str, label: str) -> None: + global s + if old not in s: + raise SystemExit(f"Zend hard-timeout patch: could not find {label}") + s = s.replace(old, new, 1) + +if '#include "zend_hrtime.h"' not in s: + replace_once( + '#include "zend_call_stack.h"\n', + '#include "zend_call_stack.h"\n' + '#if defined(__wasm32__) || defined(__wasm64__)\n' + '#include "zend_hrtime.h"\n' + '#endif\n', + "zend_call_stack include", + ) + +replace_once( + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n" + "#if defined(__wasm32__) || defined(__wasm64__)\n" + "extern void __wasm_posix_vm_interrupt_after(void *timed_out, void *vm_interrupt, zend_long seconds);\n" + "#endif\n" + "/* wasm-vm-interrupt-timer patch applied */\n", + "static void zend_set_timeout_ex(zend_long seconds, bool reset_signals);\n" + "#if defined(__wasm32__) || defined(__wasm64__)\n" + "extern void __wasm_posix_vm_interrupt_after(void *timed_out, void *vm_interrupt, zend_long seconds);\n" + "\n" + "static zend_hrtime_t zend_wasm_timeout_deadline = 0;\n" + "static zend_hrtime_t zend_wasm_hard_timeout_deadline = 0;\n" + "\n" + "#define ZEND_WASM_HRTIME_MAX ((zend_hrtime_t) -1)\n" + "#define ZEND_WASM_INTERRUPT_EARLY_NS UINT64_C(100000000)\n" + "\n" + "static zend_always_inline zend_hrtime_t zend_wasm_timeout_seconds_to_ns(zend_long seconds)\n" + "{\n" + "\tif (seconds <= 0) {\n" + "\t\treturn 0;\n" + "\t}\n" + "\tif ((zend_hrtime_t) seconds > ZEND_WASM_HRTIME_MAX / (zend_hrtime_t) ZEND_NANO_IN_SEC) {\n" + "\t\treturn ZEND_WASM_HRTIME_MAX;\n" + "\t}\n" + "\treturn (zend_hrtime_t) seconds * (zend_hrtime_t) ZEND_NANO_IN_SEC;\n" + "}\n" + "\n" + "static zend_always_inline zend_hrtime_t zend_wasm_deadline_after(zend_hrtime_t now, zend_hrtime_t delay_ns)\n" + "{\n" + "\tif (delay_ns > ZEND_WASM_HRTIME_MAX - now) {\n" + "\t\treturn ZEND_WASM_HRTIME_MAX;\n" + "\t}\n" + "\treturn now + delay_ns;\n" + "}\n" + "\n" + "static zend_always_inline void zend_wasm_clear_timeout_deadlines(void)\n" + "{\n" + "\tzend_wasm_timeout_deadline = 0;\n" + "\tzend_wasm_hard_timeout_deadline = 0;\n" + "}\n" + "\n" + "static zend_always_inline void zend_wasm_record_timeout_deadline(zend_long seconds)\n" + "{\n" + "\tzend_hrtime_t now = zend_hrtime();\n" + "\tzend_hrtime_t timeout_ns = zend_wasm_timeout_seconds_to_ns(seconds);\n" + "\tzend_wasm_timeout_deadline = zend_wasm_deadline_after(now, timeout_ns);\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\tzend_hrtime_t hard_ns = zend_wasm_timeout_seconds_to_ns(EG(hard_timeout));\n" + "\t\tzend_wasm_hard_timeout_deadline = zend_wasm_deadline_after(zend_wasm_timeout_deadline, hard_ns);\n" + "\t} else {\n" + "\t\tzend_wasm_hard_timeout_deadline = 0;\n" + "\t}\n" + "}\n" + "\n" + "static zend_always_inline bool zend_wasm_hard_timeout_expired(void)\n" + "{\n" + "\tzend_hrtime_t now;\n" + "\tif (EG(hard_timeout) <= 0 || zend_wasm_hard_timeout_deadline == 0) {\n" + "\t\treturn false;\n" + "\t}\n" + "\tnow = zend_hrtime();\n" + "\treturn now >= zend_wasm_hard_timeout_deadline\n" + "\t\t|| zend_wasm_hard_timeout_deadline - now <= ZEND_WASM_INTERRUPT_EARLY_NS;\n" + "}\n" + "\n" + "static ZEND_COLD void zend_wasm_hard_timeout_exit(void)\n" + "{\n" + "\tconst char *error_filename = NULL;\n" + "\tuint32_t error_lineno = 0;\n" + "\tchar log_buffer[2048];\n" + "\tint output_len = 0;\n" + "\n" + "\tif (zend_is_compiling()) {\n" + "\t\terror_filename = ZSTR_VAL(zend_get_compiled_filename());\n" + "\t\terror_lineno = zend_get_compiled_lineno();\n" + "\t} else if (zend_is_executing()) {\n" + "\t\terror_filename = zend_get_executed_filename();\n" + "\t\tif (error_filename[0] == '[') {\n" + "\t\t\terror_filename = NULL;\n" + "\t\t\terror_lineno = 0;\n" + "\t\t} else {\n" + "\t\t\terror_lineno = zend_get_executed_lineno();\n" + "\t\t}\n" + "\t}\n" + "\tif (!error_filename) {\n" + "\t\terror_filename = \"Unknown\";\n" + "\t}\n" + "\n" + "\toutput_len = snprintf(log_buffer, sizeof(log_buffer), \"\\nFatal error: Maximum execution time of \" ZEND_LONG_FMT \"+\" ZEND_LONG_FMT \" seconds exceeded (terminated) in %s on line %d\\n\", EG(timeout_seconds), EG(hard_timeout), error_filename, error_lineno);\n" + "\tif (output_len > 0) {\n" + "\t\tzend_quiet_write(2, log_buffer, MIN(output_len, sizeof(log_buffer)));\n" + "\t}\n" + "\t_exit(124);\n" + "}\n" + "#endif\n" + "/* wasm-vm-interrupt-hard-timeout patch applied */\n", + "wasm timeout declaration", +) + +replace_once( + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * Schedule the cooperative Wasm VM interrupt only for the normal\n" + "\t * timeout phase (or for seconds=0 cancellation). PHP's native\n" + "\t * ZEND_MAX_EXECUTION_TIMERS path sets EG(timed_out) before arming\n" + "\t * hard_timeout, and that hard timeout must continue to be enforced\n" + "\t * by the POSIX timer signal path so PHP reports n+hard seconds and\n" + "\t * exits like a normal POSIX build.\n" + "\t */\n" + "\tif (seconds <= 0 || !zend_atomic_bool_load_ex(&EG(timed_out))) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t}\n" + "# endif\n" + "\tzend_max_execution_timer_settime(seconds);\n", + "#elif defined(ZEND_MAX_EXECUTION_TIMERS)\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * Host-side timers set Zend's cooperative VM interrupt flags. They\n" + "\t * cannot asynchronously unwind an already-running Wasm function, so keep\n" + "\t * absolute deadlines here and report the native hard-timeout diagnostic\n" + "\t * if PHP first regains control after max_execution_time+hard_timeout.\n" + "\t */\n" + "\tif (seconds <= 0) {\n" + "\t\tzend_wasm_clear_timeout_deadlines();\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t} else if (!zend_atomic_bool_load_ex(&EG(timed_out))) {\n" + "\t\tzend_wasm_record_timeout_deadline(seconds);\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), seconds);\n" + "\t}\n" + "# endif\n" + "\tzend_max_execution_timer_settime(seconds);\n", + "wasm timeout scheduling", +) + +replace_once( + "#else\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\tzend_set_timeout_ex(0, 1);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/*\n" + "\t * When the cooperative Wasm VM interrupt observes the soft timeout\n" + "\t * before the POSIX signal is delivered, the disarm above prevents PHP's\n" + "\t * native signal handler from arming hard_timeout. Re-arm the cooperative\n" + "\t * interrupt for the shutdown hard-timeout window so runaway shutdown\n" + "\t * handlers terminate like they do on a POSIX build.\n" + "\t */\n" + "\tif (EG(hard_timeout) > 0) {\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), EG(hard_timeout));\n" + "\t\tEG(hard_timeout) = 0;\n" + "\t}\n" + "# endif\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", + "#else\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\tif (zend_wasm_hard_timeout_expired()) {\n" + "\t\tzend_wasm_hard_timeout_exit();\n" + "\t}\n" + "# endif\n" + "\tzend_atomic_bool_store_ex(&EG(timed_out), false);\n" + "\t/* A responsive VM timeout follows POSIX zend_timeout(): disarm before shutdown. */\n" + "\tzend_set_timeout_ex(0, 1);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\t/* Give shutdown execution a fresh ordinary timeout phase. */\n" + "\tif (EG(timeout_seconds) > 0) {\n" + "\t\tzend_wasm_record_timeout_deadline(EG(timeout_seconds));\n" + "\t\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), EG(timeout_seconds));\n" + "\t}\n" + "# endif\n" + "#endif\n\n" + "\tzend_error_noreturn(E_ERROR, \"Maximum execution time of \" ZEND_LONG_FMT \" second%s exceeded\", EG(timeout_seconds), EG(timeout_seconds) == 1 ? \"\" : \"s\");\n", + "wasm zend_timeout body", +) + +replace_once( + "#elif ZEND_MAX_EXECUTION_TIMERS\n" + "\tzend_max_execution_timer_settime(0);\n" + "#elif defined(HAVE_SETITIMER)\n", + "#elif ZEND_MAX_EXECUTION_TIMERS\n" + "\tzend_max_execution_timer_settime(0);\n" + "# if defined(__wasm32__) || defined(__wasm64__)\n" + "\tzend_wasm_clear_timeout_deadlines();\n" + "\t__wasm_posix_vm_interrupt_after(&EG(timed_out), &EG(vm_interrupt), 0);\n" + "# endif\n" + "#elif defined(HAVE_SETITIMER)\n", + "zend_unset_timeout max execution timer body", +) + +p.write_text(s) +PY +fi + +# The cooperative Wasm timeout does not use SIGRTMIN. Keep PHP from replacing +# or unblocking the application's SIGRTMIN disposition merely because a caller +# asks zend_set_timeout() to reset the native timer signal handler. +if [ -f Zend/zend_execute_API.c ] \ + && ! grep -q "wasm-vm-interrupt-no-sigrtmin patch applied" Zend/zend_execute_API.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("Zend/zend_execute_API.c") +s = p.read_text() +block = """\tif (reset_signals) { +\t\tsigset_t sigset; +\t\tstruct sigaction act; + +\t\tact.sa_sigaction = zend_timeout_handler; +\t\tsigemptyset(&act.sa_mask); +\t\tact.sa_flags = SA_ONSTACK | SA_SIGINFO; +\t\tsigaction(SIGRTMIN, &act, NULL); +\t\tsigemptyset(&sigset); +\t\tsigaddset(&sigset, SIGRTMIN); +\t\tsigprocmask(SIG_UNBLOCK, &sigset, NULL); +\t} +""" +replacement = ( + "# if !defined(__wasm32__) && !defined(__wasm64__)\n" + + block + + "# endif\n" + + "\t/* wasm-vm-interrupt-no-sigrtmin patch applied */\n" +) +if s.count(block) != 1: + raise SystemExit("Zend SIGRTMIN patch: expected one native max-timer signal block") +p.write_text(s.replace(block, replacement, 1)) +PY +fi + +# PHP's DBA extension keeps an in-process lock guard because some platforms +# allow same-process read/write opens that the extension wants to reject. For +# DB-lock mode, upstream replaces info->path with the stream's opened_path only +# after the first guard check. On wasm-posix this means a later relative-path +# open can miss an already-open canonical-path handle and report "Read during +# write: allowed" for the built-in flatfile/inifile handlers. Repeat the guard +# after DB-lock path canonicalization, before taking the stream lock. +if [ -f ext/dba/dba.c ] \ + && ! grep -q "wasm-dba-db-lock-path-conflict patch applied" ext/dba/dba.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("ext/dba/dba.c") +s = p.read_text() + +find_func = '''static dba_info *php_dba_find(const zend_string *path) +{ +\tzend_resource *le; +\tdba_info *info; +\tzend_long numitems, i; + +\tnumitems = zend_hash_next_free_element(&EG(regular_list)); +\tfor (i=1; itype == le_db || le->type == le_pdb) { +\t\t\tinfo = (dba_info *)(le->ptr); +\t\t\tif (zend_string_equals(path, info->path)) { +\t\t\t\treturn (dba_info *)(le->ptr); +\t\t\t} +\t\t} +\t} + +\treturn NULL; +} +/* }}} */ +''' + +helper = find_func + ''' + +static bool php_dba_lock_conflicts(const dba_info *info, int lock_mode) +{ +\tdba_info *other; + +\tif ((other = php_dba_find(info->path)) == NULL) { +\t\treturn false; +\t} + +\treturn ( (lock_mode&LOCK_EX) && (other->lock.mode&(LOCK_EX|LOCK_SH)) ) +\t || ( (other->lock.mode&LOCK_EX) && (lock_mode&(LOCK_EX|LOCK_SH)) ); +} +/* wasm-dba-db-lock-path-conflict patch applied */ +''' + +if find_func not in s: + raise SystemExit("DBA lock patch: could not find php_dba_find") +s = s.replace(find_func, helper, 1) + +s = s.replace( + "\tdba_info *info, *other;\n", + "\tdba_info *info;\n", + 1, +) + +old_check = '''\tif (hptr->flags & DBA_LOCK_ALL) { +\t\tif ((other = php_dba_find(info->path)) != NULL) { +\t\t\tif ( ( (lock_mode&LOCK_EX) && (other->lock.mode&(LOCK_EX|LOCK_SH)) ) +\t\t\t || ( (other->lock.mode&LOCK_EX) && (lock_mode&(LOCK_EX|LOCK_SH)) ) +\t\t\t ) { +\t\t\t\terror = "Unable to establish lock (database file already open)"; /* force failure exit */ +\t\t\t} +\t\t} +\t} +''' + +new_check = '''\tif ((hptr->flags & DBA_LOCK_ALL) && php_dba_lock_conflicts(info, lock_mode)) { +\t\terror = "Unable to establish lock (database file already open)"; /* force failure exit */ +\t} +''' + +if old_check not in s: + raise SystemExit("DBA lock patch: could not find initial conflict check") +s = s.replace(old_check, new_check, 1) + +old_after_stream_open = '''\t\tif (!info->lock.fp) { +\t\t\tdba_close(info); +\t\t\t/* stream operation already wrote an error message */ +\t\t\tFREE_PERSISTENT_RESOURCE_KEY(); +\t\t\tRETURN_FALSE; +\t\t} +\t\tif (!error && !php_stream_supports_lock(info->lock.fp)) { +''' + +new_after_stream_open = '''\t\tif (!info->lock.fp) { +\t\t\tdba_close(info); +\t\t\t/* stream operation already wrote an error message */ +\t\t\tFREE_PERSISTENT_RESOURCE_KEY(); +\t\t\tRETURN_FALSE; +\t\t} +\t\tif (!error && is_db_lock && (hptr->flags & DBA_LOCK_ALL) && php_dba_lock_conflicts(info, lock_mode)) { +\t\t\terror = "Unable to establish lock (database file already open)"; /* force failure exit */ +\t\t} +\t\tif (!error && !php_stream_supports_lock(info->lock.fp)) { +''' + +if old_after_stream_open not in s: + raise SystemExit("DBA lock patch: could not find post-stream-open lock block") +s = s.replace(old_after_stream_open, new_after_stream_open, 1) + +p.write_text(s) +PY +fi + +# PHP requires one shared-memory backend to be compiled even when callers use +# opcache.file_cache_only=1. The MAP_ANON backend is the only viable build-time +# choice for this target, but Kandelo does not yet provide cross-process +# MAP_SHARED: the normal opcache SHM mode would therefore give FPM workers +# divergent cache/lock state. Enable the backend only so the file-cache path can +# be built, then add a target guard below that rejects opcache startup unless +# file-cache-only mode is explicitly configured. This is a documented runtime +# boundary, not a claim that the configure probe's fork-sharing semantics pass. if [ -f configure ] && ! grep -q "wasm-opcache patch applied" configure; then perl -i.bak -0pe 's/ have_shm_mmap_anon=no\n ;;/ have_shm_mmap_anon=yes\n ;; # wasm-opcache patch applied/' configure rm -f configure.bak fi -# Default opcache.enable to "0" (was "1"). Rationale: PHP's built-in dev -# server (`php -S`) uses the cli-server SAPI, which IS in opcache's -# supported_sapis list (only the bare `cli` SAPI is gated by -# `opcache.enable_cli`). With the upstream default of "1", every CLI -# invocation under cli-server pays opcache MINIT cost (128MB SHM -# allocation + per-request validation) — heavy enough to push the -# wordpress-site-editor E2E test (packages/registry/wordpress/test/) past its -# 10-minute install deadline on CI runners. Our LAMP/WP/nginx-php -# php-fpm demos explicitly set opcache.enable=1 in /etc/php.ini, so -# flipping the compile-time default to "0" preserves the per-worker -# bytecode-cache win for FPM while leaving CLI / cli-server behavior -# pre-PR-identical. -if [ -f ext/opcache/zend_accelerator_module.c ] \ - && ! grep -q "wasm-opcache enable=0 patch applied" ext/opcache/zend_accelerator_module.c; then - sed -i.bak \ - -e 's|STD_PHP_INI_BOOLEAN("opcache.enable" , "1"|STD_PHP_INI_BOOLEAN("opcache.enable" , "0"|' \ - ext/opcache/zend_accelerator_module.c - # Drop a marker comment so re-running the patch is idempotent. - if ! grep -q "wasm-opcache enable=0 patch applied" ext/opcache/zend_accelerator_module.c; then - sed -i.bak2 '/STD_PHP_INI_BOOLEAN("opcache.enable" , "0"/i\ -/* wasm-opcache enable=0 patch applied — see packages/registry/php/build-php.sh */ -' ext/opcache/zend_accelerator_module.c - fi - rm -f ext/opcache/zend_accelerator_module.c.bak ext/opcache/zend_accelerator_module.c.bak2 +if [ -f ext/opcache/ZendAccelerator.c ] \ + && ! grep -q "kandelo-opcache-file-cache-only" ext/opcache/ZendAccelerator.c; then + python3 - <<'PY' +from pathlib import Path + +p = Path("ext/opcache/ZendAccelerator.c") +s = p.read_text() +marker = "\tfile_cache_only = ZCG(accel_directives).file_cache_only;\n" +guard = """#if defined(__wasm__) /* kandelo-opcache-file-cache-only */ +\tif (!ZCG(accel_directives).file_cache_only) { +\t\taccel_startup_ok = false; +\t\tzend_accel_error_noreturn( +\t\t\tACCEL_LOG_FATAL, +\t\t\t\"Kandelo requires opcache.file_cache_only=1 because cross-process MAP_SHARED is unavailable.\"); +\t\treturn SUCCESS; +\t} +#endif +\tfile_cache_only = ZCG(accel_directives).file_cache_only; +""" +if marker not in s: + raise SystemExit("opcache file-cache-only guard: startup marker not found") +p.write_text(s.replace(marker, guard, 1)) +PY +fi + +# PHP's configure enables Zend max-execution timers only on Linux hosts even +# when --enable-zend-max-execution-timers is explicitly requested. Allow Wasm +# through that compile-time gate because the target-specific implementation +# above replaces Linux SIGEV_THREAD_ID with Kandelo's cooperative host hook; +# this does not claim that native thread-targeted timer delivery is available. +if [ -f configure ] && ! grep -q "wasm-zend-max-execution-timers patch applied" configure; then + perl -i.bak -0pe "s/ \\*linux\\*\\) :\\n ;; #\\(\\n \\*\\) :\\n ZEND_MAX_EXECUTION_TIMERS='no' ;;/ *linux*|wasm32*|wasm64*) :\\n ;; #(\\n *) :\\n ZEND_MAX_EXECUTION_TIMERS='no' ;; # wasm-zend-max-execution-timers patch applied/" configure + rm -f configure.bak +fi + +# ext/sockets gates its Linux classic-BPF socket option implementation only on +# SO_ATTACH_REUSEPORT_CBPF. Kandelo's musl headers expose that socket option +# number but do not provide 's `struct sock_filter`/ +# `struct sock_fprog` definitions. Build the rest of the sockets extension and +# omit only the BPF option arm when the platform lacks those Linux filter +# declarations. +if [ -f ext/sockets/sockets.c ] \ + && ! grep -q "wasm-sockets-cbpf-guard patch applied" ext/sockets/sockets.c; then + python3 - <<'PY' +from pathlib import Path +p = Path("ext/sockets/sockets.c") +s = p.read_text() +s = s.replace( + "#ifdef SO_ATTACH_REUSEPORT_CBPF\n", + "#if defined(SO_ATTACH_REUSEPORT_CBPF) && defined(HAVE_LINUX_FILTER_H) /* wasm-sockets-cbpf-guard patch applied */\n", +) +p.write_text(s) +PY fi echo "==> Configuring PHP for Wasm (CLI + FPM, single tree)..." -# Drop a stale config.cache from a previous build whose env (CPPFLAGS, -# PKG_CONFIG_PATH, etc.) may not match this run. autoconf would -# otherwise reject the cache with "changes in the environment can -# compromise the build" — recovering requires a fresh cache anyway. -rm -f "$SCRIPT_DIR/config.cache" +# Keep autoconf's cache inside the disposable build directory. Package builds +# must not race on or leave generated state in the registry recipe directory. +rm -f "$CONFIG_CACHE" +if [ -f Makefile ] && ! grep -q 'ext/zend_test' Makefile; then + echo "==> Existing PHP Makefile lacks zend_test shared-extension rules; reconfiguring..." + rm -f Makefile config.cache +fi +if [ -f Makefile ] && ! grep -q 'ext/zip' Makefile; then + echo "==> Existing PHP Makefile lacks the zip shared-extension rules; reconfiguring..." + rm -f Makefile config.cache +fi +if [ -f Makefile ] && grep -q -- '-rpath' Makefile; then + echo "==> Existing PHP Makefile contains ELF rpath flags unsupported by wasm-ld; reconfiguring..." + rm -f Makefile config.cache +fi +if [ -f Makefile ]; then + # Keep the local configure output aligned with the PHPT coverage profile + # this script now requests. These are bundled/general-purpose extensions, + # not test-only shims; enabling them lets upstream PHPTs exercise the + # Kandelo POSIX surface instead of being skipped as "extension not loaded". + for ext in bcmath calendar dba ftp iconv pcntl posix shmop soap sockets sysvmsg sysvsem sysvshm; do + if ! grep -q "phpext_${ext}_ptr" main/internal_functions.c 2>/dev/null; then + echo "==> Existing PHP Makefile lacks ${ext}; reconfiguring..." + rm -f Makefile config.cache + break + fi + done +fi +if [ -f Makefile ] && ! grep -q '#define ICONV_ALIASED_LIBICONV 1' main/php_config.h 2>/dev/null; then + echo "==> Existing PHP Makefile does not use GNU libiconv aliases; reconfiguring..." + rm -f Makefile config.cache +fi +if [ -f Makefile ] && ! grep -q '^#define PHP_OS "Kandelo"$' main/php_config.h 2>/dev/null; then + echo "==> Existing PHP Makefile embeds the build host OS; reconfiguring..." + rm -f Makefile config.cache +fi if [ ! -f Makefile ]; then # LDFLAGS notes (kept OUTSIDE the line-continuation block below # because `# comment` lines inside a `\`-continued bash block @@ -165,6 +841,17 @@ if [ ! -f Makefile ]; then # invokes on our wasm port — but the import has to resolve at # instantiation time). # + # curl.so absorbs libcurl.a but deliberately leaves libc, zlib, and + # OpenSSL unresolved so the process keeps one copy of each library's + # state. The second -u group is the measured subset that base PHP does not + # otherwise pull into php.wasm; --export-all then exposes those symbols to + # the side module. The post-build import-closure test guards this list. + # The following group forces libc symbols intl.so imports but base PHP + # never references (allocator, wide-char, math, and the pthread mutex/ + # cond/TLS that ICU's UMutex uses). They must resolve to php.wasm's own + # musl so intl.so shares one libc state — one allocator, one pthread key + # table; without -u they never enter php.wasm and intl.so fails to load. + # # -Wl,-z,stack-size=4194304: 4 MB wasm stack. The default wasm-ld # stack is 64 KB, which sits ~100 KB above PHP's `alloc_globals` # data segment. Opcache's PASS_6 (DFA-based SSA optimization) calls @@ -177,28 +864,102 @@ if [ ! -f Makefile ]; then # out of bounds" because it tries to dereference the now-bogus heap # pointer. 4 MB gives PASS_6 enough headroom for any function that # passes its own `blocks*vars > 4M` size guard. + # + # ac_cv_lib_iconv_libiconv=yes: PHP's autoconf probe calls `libiconv()` + # with an old-style no-argument prototype. That is tolerated by native ELF + # linkers but invalid for WebAssembly's typed call graph, so wasm-ld rejects + # the probe before configure can discover that GNU libiconv's header maps + # iconv/iconv_open/iconv_close to libiconv/libiconv_open/libiconv_close. + # Preseeding the cache with the known result keeps the cross-compile build + # aligned with the actual library/header ABI rather than falling back to + # musl's narrower iconv implementation. + # + # ac_cv_lib_zip_*: PHP checks newer libzip APIs by linking each probe into + # the complete PHP dependency graph. On this cross target, unrelated + # archive members can make that whole-program probe fail even though a + # direct link against the exact resolver-built libzip archive succeeds. + # Seed only symbols verified by the libzip recipe's upstream source graph; + # this preserves the actual 1.11.4 API instead of silently compiling an + # older, reduced ZipArchive surface. + # + # musl exposes Linux unshare() as an ENOSYS stub. PHP must not advertise + # pcntl_unshare() when this target cannot provide namespace isolation, so + # override the cross probe with the target's real capability. PKG_CONFIG_PATH="$DEP_PKG_CONFIG_PATH" \ CPPFLAGS="$DEP_CPPFLAGS" \ LDFLAGS="$DEP_LDFLAGS -ldl -Wl,--export-all \ -u setgid -u setuid -u initgroups -u writev -u asctime \ +-u rand -u srand -u remove \ +-u inet_pton -u inet_ntop -u sched_yield -u alarm -u basename \ +-u OCSP_basic_verify -u OCSP_cert_status_str -u OCSP_crl_reason_str \ +-u OCSP_response_status_str -u SSL_alert_desc_string_long \ +-u aligned_alloc -u div -u modf -u round -u tanhf \ +-u swprintf -u wcstod -u wcstof -u wcstol -u wcstold \ +-u wcstoll -u wcstoul -u wcstoull -u wmemchr -u wmemcmp \ +-u pthread_cond_broadcast -u pthread_cond_destroy -u pthread_cond_signal \ +-u pthread_cond_timedwait -u pthread_cond_wait -u pthread_detach \ +-u pthread_getspecific -u pthread_key_create -u pthread_self \ +-u pthread_setspecific \ -Wl,-z,stack-size=4194304" \ + ZLIB_CFLAGS="$ZLIB_CFLAGS_VALUE" \ + ZLIB_LIBS="$ZLIB_LIBS_VALUE" \ + SQLITE_CFLAGS="$SQLITE_CFLAGS_VALUE" \ + SQLITE_LIBS="$SQLITE_LIBS_VALUE" \ + OPENSSL_CFLAGS="$OPENSSL_CFLAGS_VALUE" \ + OPENSSL_LIBS="$OPENSSL_LIBS_VALUE" \ + LIBXML_CFLAGS="$LIBXML_CFLAGS_VALUE" \ + LIBXML_LIBS="$LIBXML_LIBS_VALUE" \ + ICONV_CFLAGS="$ICONV_CFLAGS_VALUE" \ + ICONV_LIBS="$ICONV_LIBS_VALUE" \ + LIBZIP_CFLAGS="$LIBZIP_CFLAGS_VALUE" \ + LIBZIP_LIBS="$LIBZIP_LIBS_VALUE" \ + ac_cv_lib_zip_zip_file_set_mtime=yes \ + ac_cv_lib_zip_zip_file_set_encryption=yes \ + ac_cv_lib_zip_zip_libzip_version=yes \ + ac_cv_lib_zip_zip_register_progress_callback_with_state=yes \ + ac_cv_lib_zip_zip_register_cancel_callback_with_state=yes \ + ac_cv_lib_zip_zip_compression_method_supported=yes \ + CURL_CFLAGS="$CURL_CFLAGS_VALUE" \ + CURL_LIBS="$CURL_LIBS_VALUE" \ + PHP_UNAME="Kandelo wasm32-posix-kernel" \ + ac_cv_lib_iconv_libiconv=yes \ + ac_cv_lib_curl_curl_easy_perform=yes \ + ac_cv_func_unshare=no \ wasm32posix-configure \ --disable-all \ + --disable-rpath \ --disable-cgi \ --disable-phpdbg \ --enable-cli \ --enable-fpm \ --enable-opcache \ + --enable-intl=shared \ --enable-mbstring \ --disable-mbregex \ --enable-ctype \ --enable-tokenizer \ --enable-filter \ - --enable-phar \ + --enable-bcmath \ + --enable-calendar \ + --enable-dba \ + --enable-ftp \ + --with-iconv="$LIBICONV_PREFIX" \ + --with-curl=shared \ + --enable-pcntl \ + --enable-phar=shared \ + --enable-posix \ + --enable-shmop \ + --enable-soap \ + --enable-sockets \ + --enable-sysvmsg \ + --enable-sysvsem \ + --enable-sysvshm \ + --enable-zend-test=shared \ --without-valgrind \ --without-pcre-jit \ --disable-fiber-asm \ --disable-zend-signals \ + --enable-zend-max-execution-timers \ --enable-session \ --with-sqlite3 \ --enable-pdo \ @@ -215,32 +976,54 @@ if [ ! -f Makefile ]; then --enable-simplexml \ --enable-xmlreader \ --enable-xmlwriter \ - --cache-file="$SCRIPT_DIR/config.cache" \ - --prefix="$INSTALL_DIR" \ - CFLAGS="-O2 -gline-tables-only -DZEND_USE_ASM_ARITHMETIC=0" + --with-zip=shared \ + --cache-file="$CONFIG_CACHE" \ + --prefix="$GUEST_PREFIX" \ + --sysconfdir=/etc \ + --localstatedir=/var \ + --with-config-file-path=/etc \ + --with-config-file-scan-dir=/etc/php.d \ + CFLAGS="-O2 -gline-tables-only $REPRODUCIBLE_PREFIX_MAPS -DZEND_USE_ASM_ARITHMETIC=0" # CFLAGS includes -gline-tables-only for debug stack traces. # The debug-trace value is worth keeping. CLI inherits the same # flags; it just produces a slightly larger binary. - # Patch config.h: disable features that pass link-time checks (--allow-undefined) - # but don't actually exist in our musl sysroot + # Patch config.h: disable features that pass link-time checks + # (--allow-undefined) but are not currently usable through Kandelo's PHP + # runtime. In particular, the musl resolver exposes res_search(3), but + # PHP's DNS record APIs can block on external DNS record queries in generic + # arginfo probes. Disable the DNS search-family feature macros together so + # PHP does not register dns_get_record()/dns_get_mx() without a usable + # resolver backend. echo "==> Patching main/php_config.h for Wasm..." sed -i.bak \ -e 's/^#define HAVE_DNS_SEARCH 1/\/* #undef HAVE_DNS_SEARCH *\//' \ -e 's/^#define HAVE_DNS_SEARCH_FUNC 1/\/* #undef HAVE_DNS_SEARCH_FUNC *\//' \ -e 's/^#define HAVE_RES_NSEARCH 1/\/* #undef HAVE_RES_NSEARCH *\//' \ -e 's/^#define HAVE_RES_NDESTROY 1/\/* #undef HAVE_RES_NDESTROY *\//' \ - -e 's/^#define HAVE_DN_EXPAND 1/\/* #undef HAVE_DN_EXPAND *\//' \ - -e 's/^#define HAVE_DN_SKIPNAME 1/\/* #undef HAVE_DN_SKIPNAME *\//' \ - -e 's/^#define HAVE_FOPENCOOKIE 1/\/* #undef HAVE_FOPENCOOKIE *\//' \ + -e 's/^#define HAVE_RES_SEARCH 1/\/* #undef HAVE_RES_SEARCH *\//' \ -e 's/^#define HAVE_FUNOPEN 1/\/* #undef HAVE_FUNOPEN *\//' \ -e 's/^#define HAVE_STD_SYSLOG 1/\/* #undef HAVE_STD_SYSLOG *\//' \ -e 's/^#define HAVE_SETPROCTITLE 1/\/* #undef HAVE_SETPROCTITLE *\//' \ -e 's/^#define HAVE_SETPROCTITLE_FAST 1/\/* #undef HAVE_SETPROCTITLE_FAST *\//' \ - -e 's/^#define HAVE_PRCTL 1/\/* #undef HAVE_PRCTL *\//' \ -e 's/^#define HAVE_RAND_EGD 1/\/* #undef HAVE_RAND_EGD *\//' \ + -e 's/^#define HAVE_FORKX 1/\/* #undef HAVE_FORKX *\//' \ + -e 's/^#define HAVE_RFORK 1/\/* #undef HAVE_RFORK *\//' \ + -e 's/^#define PHP_OS .*/#define PHP_OS "Kandelo"/' \ + -e 's/^#define PHP_UNAME .*/#define PHP_UNAME "Kandelo wasm32-posix-kernel"/' \ main/php_config.h && rm -f main/php_config.h.bak + # Do not bake the host build prefix into PHP's runtime extension_dir. + # Upstream configure expands it from --prefix, but Kandelo programs run in + # a guest filesystem where the build checkout does not exist. Use the + # guest path populated by the VFS images and mounted by the PHPT harness so + # normal PHP invocations like `php -n -d extension=phar.so` work without + # requiring absolute, harness-specific extension paths. + sed -i.bak \ + -e 's|^#define CONFIGURE_COMMAND .*|#define CONFIGURE_COMMAND "Kandelo reproducible package build"|' \ + -e 's|^#define PHP_EXTENSION_DIR .*|#define PHP_EXTENSION_DIR "/usr/lib/php/extensions"|' \ + main/build-defs.h && rm -f main/build-defs.h.bak + # Remove -MMD/-MF/-MT dependency tracking flags from Makefile. # libtool doesn't understand these flags and misidentifies the source file, # causing "mv: rename foo.o" errors during compilation. @@ -264,6 +1047,48 @@ if [ ! -f Makefile ]; then && rm -f libtool.bak fi +if [ -f main/build-defs.h ]; then + sed -i.bak \ + -e 's|^#define CONFIGURE_COMMAND .*|#define CONFIGURE_COMMAND "Kandelo reproducible package build"|' \ + -e 's|^#define PHP_EXTENSION_DIR .*|#define PHP_EXTENSION_DIR "/usr/lib/php/extensions"|' \ + main/build-defs.h && rm -f main/build-defs.h.bak +fi + +# SQLite's feature probes can be distorted by the cross-linker's permitted +# undefined imports. Keep PHP aligned with the actual Kandelo SQLite package: +# sqlite3_expanded_sql() is present, while column metadata and runtime loadable +# extensions are intentionally omitted. Do not turn those missing producer +# symbols into fictional env imports in php.wasm. +# PHP's fopencookie probe is also distorted by wasm cross-linking. Kandelo's +# musl sysroot provides fopencookie(3), and PHP uses it for generic stream → +# stdio casts rather than requiring every user stream wrapper to implement its +# own stream_cast method. +if [ -f main/php_config.h ]; then + sed -i.bak \ + -e 's|^/\* #undef HAVE_SQLITE3_EXPANDED_SQL \*/|#define HAVE_SQLITE3_EXPANDED_SQL 1|' \ + -e 's|^#define HAVE_SQLITE3_COLUMN_TABLE_NAME 1|/* #undef HAVE_SQLITE3_COLUMN_TABLE_NAME */|' \ + -e 's|^/\* #undef SQLITE_OMIT_LOAD_EXTENSION \*/|#define SQLITE_OMIT_LOAD_EXTENSION 1|' \ + -e 's|^/\* #undef HAVE_FOPENCOOKIE \*/|#define HAVE_FOPENCOOKIE 1|' \ + -e 's|^/\* #undef HAVE_PRCTL \*/|#define HAVE_PRCTL 1|' \ + -e 's|^#define HAVE_FORKX 1|/* #undef HAVE_FORKX */|' \ + -e 's|^#define HAVE_RFORK 1|/* #undef HAVE_RFORK */|' \ + -e 's|^#define PHP_OS .*|#define PHP_OS "Kandelo"|' \ + -e 's|^#define PHP_UNAME .*|#define PHP_UNAME "Kandelo wasm32-posix-kernel"|' \ + main/php_config.h && rm -f main/php_config.h.bak + # PHP's generated object dependencies do not reliably notice the + # php_config.h feature override above after an incremental rebuild. Force + # the stream-casting unit to be rebuilt so generic stream→FILE* casts use + # fopencookie instead of falling back to wrapper-specific stream_cast hooks. + rm -f main/streams/cast.o main/streams/cast.lo main/streams/.libs/cast.o + rm -f ext/pcntl/pcntl.o ext/pcntl/pcntl.lo ext/pcntl/.libs/pcntl.o + # The same dependency-tracking gap can leave ext/iconv compiled against an + # older config after switching from musl iconv to GNU libiconv. Rebuild this + # unit so the libiconv header aliases are reflected in the final binary. + if grep -q '#define ICONV_ALIASED_LIBICONV 1' main/php_config.h; then + rm -f ext/iconv/iconv.o ext/iconv/iconv.lo ext/iconv/.libs/iconv.o + fi +fi + # `make` per-file rules embed `INCLUDES` from configure but ignore # `CPPFLAGS` (which only contains `-D_GNU_SOURCE`); `INCLUDES` for # our libxml2 ends up as `-I.../include/libxml` because PHP's @@ -281,6 +1106,8 @@ make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" EXTRA_CFLAGS="$EXTRA_INC_LIBX echo "==> Both PHP binaries built successfully!" +FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" + # Build opcache as a shared Zend extension (.so side module). # PHP's `make` produces PIC-compiled `.libs/ext/opcache/*.o` because # opcache's `[[outputs]]` config is "always shared", but the bundled @@ -289,9 +1116,22 @@ echo "==> Both PHP binaries built successfully!" # and feed the PIC objects to the SDK's `wasm32posix-cc -shared`, # which routes through `wasm-ld --shared --experimental-pic`. echo "==> Building opcache.so (Zend extension)..." -make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" EXTRA_CFLAGS="$EXTRA_INC_LIBXML" ext/opcache/opcache.la || true -mkdir -p "$SCRIPT_DIR/bin" -wasm32posix-cc -shared -fPIC -o "$SCRIPT_DIR/bin/opcache.so" \ +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" \ + EXTRA_CFLAGS="$EXTRA_INC_LIBXML" \ + ext/opcache/ZendAccelerator.lo \ + ext/opcache/zend_accelerator_blacklist.lo \ + ext/opcache/zend_accelerator_debug.lo \ + ext/opcache/zend_accelerator_hash.lo \ + ext/opcache/zend_accelerator_module.lo \ + ext/opcache/zend_persist.lo \ + ext/opcache/zend_persist_calc.lo \ + ext/opcache/zend_file_cache.lo \ + ext/opcache/zend_shared_alloc.lo \ + ext/opcache/zend_accelerator_util_funcs.lo \ + ext/opcache/shared_alloc_shm.lo \ + ext/opcache/shared_alloc_mmap.lo \ + ext/opcache/shared_alloc_posix.lo +wasm32posix-cc -shared -fPIC -o "$BIN_DIR/opcache.so" \ ext/opcache/.libs/ZendAccelerator.o \ ext/opcache/.libs/zend_accelerator_blacklist.o \ ext/opcache/.libs/zend_accelerator_debug.o \ @@ -305,12 +1145,149 @@ wasm32posix-cc -shared -fPIC -o "$SCRIPT_DIR/bin/opcache.so" \ ext/opcache/.libs/shared_alloc_shm.o \ ext/opcache/.libs/shared_alloc_mmap.o \ ext/opcache/.libs/shared_alloc_posix.o -echo "==> opcache.so: $(wc -c < "$SCRIPT_DIR/bin/opcache.so") bytes" +echo "==> Applying fork instrumentation to opcache.so side module..." +"$FORK_INSTRUMENT" "$BIN_DIR/opcache.so" -o "$BIN_DIR/opcache.so.instr" --entry env.fork +mv "$BIN_DIR/opcache.so.instr" "$BIN_DIR/opcache.so" +echo "==> opcache.so: $(wc -c < "$BIN_DIR/opcache.so") bytes" + +# Build ext/curl as a normal shared PHP extension. libcurl.a is PIC and is +# absorbed into curl.so; libc, zlib, and OpenSSL remain imports from php.wasm +# so dlopen does not create duplicate process-global library state. +echo "==> Building curl.so (extension)..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" \ + EXTRA_CFLAGS="$EXTRA_INC_LIBXML -I$LIBCURL_PREFIX/include -DCURL_STATICLIB" \ + ext/curl/interface.lo \ + ext/curl/multi.lo \ + ext/curl/share.lo \ + ext/curl/curl_file.lo +wasm32posix-cc -shared -fPIC -o "$BIN_DIR/curl.so" \ + ext/curl/.libs/interface.o \ + ext/curl/.libs/multi.o \ + ext/curl/.libs/share.o \ + ext/curl/.libs/curl_file.o \ + "$LIBCURL_PREFIX/lib/libcurl.a" +echo "==> curl.so: $(wc -c < "$BIN_DIR/curl.so") bytes" + +# Build Phar as a shared extension too. The PHP package intentionally keeps +# shared extensions loadable through normal `extension=...` INI directives so +# subprocesses and PHPT fixtures that opt in to extensions use the same path as +# a general PHP runtime. Shipping phar.so avoids relying on a statically linked +# Phar module while still letting callers decide whether to load it. +echo "==> Building phar.so (extension)..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" \ + EXTRA_CFLAGS="$EXTRA_INC_LIBXML" \ + ext/phar/util.lo \ + ext/phar/tar.lo \ + ext/phar/zip.lo \ + ext/phar/stream.lo \ + ext/phar/func_interceptors.lo \ + ext/phar/dirstream.lo \ + ext/phar/phar.lo \ + ext/phar/phar_object.lo \ + ext/phar/phar_path_check.lo +wasm32posix-cc -shared -fPIC -o "$BIN_DIR/phar.so" \ + ext/phar/.libs/dirstream.o \ + ext/phar/.libs/func_interceptors.o \ + ext/phar/.libs/phar.o \ + ext/phar/.libs/phar_object.o \ + ext/phar/.libs/phar_path_check.o \ + ext/phar/.libs/stream.o \ + ext/phar/.libs/tar.o \ + ext/phar/.libs/util.o \ + ext/phar/.libs/zip.o +echo "==> phar.so: $(wc -c < "$BIN_DIR/phar.so") bytes" + +# Build zend_test as a normal shared extension. Upstream php-src uses this +# extension to exercise engine edge cases through --EXTENSIONS--. Shipping it +# as an opt-in module keeps the PHP runtime general-purpose while letting the +# PHPT harness run those tests without pretending the extension is present. +echo "==> Building zend_test.so (extension)..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" \ + EXTRA_CFLAGS="$EXTRA_INC_LIBXML" \ + ext/zend_test/test.lo \ + ext/zend_test/observer.lo \ + ext/zend_test/fiber.lo \ + ext/zend_test/iterators.lo \ + ext/zend_test/object_handlers.lo +wasm32posix-cc -shared -fPIC -o "$BIN_DIR/zend_test.so" \ + ext/zend_test/.libs/*.o +echo "==> zend_test.so: $(wc -c < "$BIN_DIR/zend_test.so") bytes" + +# Build ext/zip as a normal shared PHP extension. libzip is linked statically +# into the side module; its zlib and libc/PHP imports resolve from php.wasm, +# whose --export-all link is forced to retain the few libc functions used only +# by libzip. +echo "==> Building zip.so (extension)..." +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" \ + EXTRA_CFLAGS="$EXTRA_INC_LIBXML -I$LIBZIP_PREFIX/include" \ + ext/zip/php_zip.lo \ + ext/zip/zip_stream.lo +wasm32posix-cc -shared -fPIC -o "$BIN_DIR/zip.so" \ + ext/zip/.libs/php_zip.o \ + ext/zip/.libs/zip_stream.o \ + "$LIBZIP_PREFIX/lib/libzip.a" +echo "==> zip.so: $(wc -c < "$BIN_DIR/zip.so") bytes" + +# Build intl as a shared .so, same libtool workaround as opcache: make compiles +# the PIC objects under ext/intl/**/.libs/ but the bundled libtool can't emit the +# final .so on this target, so we link it with `wasm32posix-cc -shared`. intl +# statically absorbs ICU and libc++/libc++abi so neither enters php.wasm; the ICU +# common data stays out of the .so as icu.dat (loaded by intl-icu-data-loader.c). +echo "==> Building intl.so (PHP extension)..." +# Ask PHP's generated Makefile for the shared extension's complete object list +# and build those targets directly. Invoking intl.la would deliberately reach +# the unsupported libtool side-module link, while `|| true` would also hide a +# genuine compile failure and could link a partial extension. +INTL_LO_TARGETS_RAW="$(make -s --no-print-directory -f Makefile -f - print-intl-objects <<'MAKE' +.PHONY: print-intl-objects +print-intl-objects: + @printf '%s\n' $(shared_objects_intl) +MAKE +)" +mapfile -t INTL_LO_TARGETS <<< "$INTL_LO_TARGETS_RAW" +[ "${#INTL_LO_TARGETS[@]}" -gt 0 ] || { + echo "ERROR: PHP Makefile did not declare shared_objects_intl" >&2 + exit 1 +} +make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" \ + EXTRA_CFLAGS="$EXTRA_INC_LIBXML" \ + "${INTL_LO_TARGETS[@]}" + +# Compile the icu.dat loader (PIC) that feeds ICU its common data at dlopen. +wasm32posix-cc -fPIC -O2 -c "$SCRIPT_DIR/intl-icu-data-loader.c" \ + -I"$ICU_PREFIX/include" -o ext/intl/kandelo_icu_data_loader.o + +# Collect every PIC object libtool produced for ext/intl (top dir + the +# collator/, dateformat/, formatter/, … subdirs each have their own .libs/). +mapfile -t INTL_OBJS < <(find ext/intl -path '*/.libs/*.o' | sort) +[ "${#INTL_OBJS[@]}" -gt 0 ] || { echo "ERROR: no ext/intl PIC objects found — did 'make ext/intl/intl.la' compile?" >&2; exit 1; } +echo "==> linking intl.so from ${#INTL_OBJS[@]} objects + ICU static libs + libc++" + +# wasm-ld resolves archive back-references without --start-group, so the ICU +# archives are listed in dependency order (i18n -> io -> uc -> data), then +# libc++/libc++abi. A -shared PIC module requires every input to be PIC, so the +# libc++ PIC variants are named explicitly to win over the non-PIC sysroot ones. +wasm32posix-cc -shared -fPIC -Wl,--export=__tls_base -o "$BIN_DIR/intl.so" \ + "${INTL_OBJS[@]}" \ + ext/intl/kandelo_icu_data_loader.o \ + "$ICU_PREFIX/lib/libicui18n.a" \ + "$ICU_PREFIX/lib/libicuio.a" \ + "$ICU_PREFIX/lib/libicuuc.a" \ + "$ICU_PREFIX/lib/libicudata.a" \ + "$LIBCXX_PREFIX/lib/libc++-pic.a" \ + "$LIBCXX_PREFIX/lib/libc++abi-pic.a" +echo "==> intl.so: $(wc -c < "$BIN_DIR/intl.so") bytes" + +# Make the program package's runtime closure self-contained. ICU remains the +# build dependency/source of truth; PHP's archive carries these exact resolved +# bytes so normal local/fetched materialization can stage the declared guest +# file without inspecting the library cache. +cp "$ICU_PREFIX/share/icu.dat" "$BIN_DIR/icu.dat" +chmod 0644 "$BIN_DIR/icu.dat" # Copy to bin/ with .wasm extension (needed for Vite browser demos) -mkdir -p "$SCRIPT_DIR/bin" -cp sapi/cli/php "$SCRIPT_DIR/bin/php.wasm" -cp sapi/fpm/php-fpm "$SCRIPT_DIR/bin/php-fpm.wasm" +cp sapi/cli/php "$BIN_DIR/php.wasm" +cp sapi/fpm/php-fpm "$BIN_DIR/php-fpm.wasm" # CLI and FPM both retain libc paths that can reach kernel_fork # (system/popen/fork wrappers for CLI, worker forks for FPM), so both @@ -320,28 +1297,39 @@ cp sapi/fpm/php-fpm "$SCRIPT_DIR/bin/php-fpm.wasm" # invalidate them. wasm-fork-instrument auto-discovers fork paths via # call-graph analysis; no onlylist file is required. WASM_OPT="$(command -v wasm-opt 2>/dev/null || true)" -if [ -n "$WASM_OPT" ]; then - echo "==> Optimizing CLI binary with wasm-opt -O2..." - "$WASM_OPT" -O2 "$SCRIPT_DIR/bin/php.wasm" -o "$SCRIPT_DIR/bin/php.wasm" - - echo "==> Optimizing FPM binary with wasm-opt -O2..." - "$WASM_OPT" -O2 "$SCRIPT_DIR/bin/php-fpm.wasm" -o "$SCRIPT_DIR/bin/php-fpm.wasm" +if [ -z "$WASM_OPT" ]; then + echo "ERROR: wasm-opt is required for deterministic PHP package outputs" >&2 + exit 1 fi +echo "==> Optimizing CLI binary with wasm-opt -O2..." +"$WASM_OPT" -O2 "$BIN_DIR/php.wasm" -o "$BIN_DIR/php.wasm" + +echo "==> Optimizing FPM binary with wasm-opt -O2..." +"$WASM_OPT" -O2 "$BIN_DIR/php-fpm.wasm" -o "$BIN_DIR/php-fpm.wasm" -FORK_INSTRUMENT="$REPO_ROOT/scripts/run-wasm-fork-instrument.sh" echo "==> Applying fork instrumentation to CLI..." -"$FORK_INSTRUMENT" "$SCRIPT_DIR/bin/php.wasm" -o "$SCRIPT_DIR/bin/php.wasm.instr" -mv "$SCRIPT_DIR/bin/php.wasm.instr" "$SCRIPT_DIR/bin/php.wasm" +"$FORK_INSTRUMENT" "$BIN_DIR/php.wasm" -o "$BIN_DIR/php.wasm.instr" +mv "$BIN_DIR/php.wasm.instr" "$BIN_DIR/php.wasm" echo "==> Applying fork instrumentation to FPM..." -"$FORK_INSTRUMENT" "$SCRIPT_DIR/bin/php-fpm.wasm" -o "$SCRIPT_DIR/bin/php-fpm.wasm.instr" -mv "$SCRIPT_DIR/bin/php-fpm.wasm.instr" "$SCRIPT_DIR/bin/php-fpm.wasm" +"$FORK_INSTRUMENT" "$BIN_DIR/php-fpm.wasm" -o "$BIN_DIR/php-fpm.wasm.instr" +mv "$BIN_DIR/php-fpm.wasm.instr" "$BIN_DIR/php-fpm.wasm" + +chmod 0755 "$BIN_DIR/php.wasm" "$BIN_DIR/php-fpm.wasm" -ls -la "$SCRIPT_DIR/bin/php.wasm" "$SCRIPT_DIR/bin/php-fpm.wasm" +ls -la "$BIN_DIR/php.wasm" "$BIN_DIR/php-fpm.wasm" # Install into local-binaries/ so the resolver picks the freshly-built # binaries over the fetched release. -source "$REPO_ROOT/scripts/install-local-binary.sh" -install_local_binary php "$SCRIPT_DIR/bin/php.wasm" php.wasm -install_local_binary php "$SCRIPT_DIR/bin/php-fpm.wasm" php-fpm.wasm -install_local_binary php "$SCRIPT_DIR/bin/opcache.so" +if [ -z "${WASM_POSIX_DEP_OUT_DIR:-}" ]; then + source "$REPO_ROOT/scripts/install-local-binary.sh" + install_local_binary php "$BIN_DIR/php.wasm" php.wasm + install_local_binary php "$BIN_DIR/php-fpm.wasm" php-fpm.wasm + install_local_binary php "$BIN_DIR/opcache.so" + install_local_binary php "$BIN_DIR/curl.so" + install_local_binary php "$BIN_DIR/phar.so" + install_local_binary php "$BIN_DIR/zend_test.so" + install_local_binary php "$BIN_DIR/zip.so" + install_local_binary php "$BIN_DIR/intl.so" + install_local_runtime_file php "$BIN_DIR/icu.dat" +fi diff --git a/packages/registry/php/build.toml b/packages/registry/php/build.toml index eae64edbc8..3c86c444d3 100644 --- a/packages/registry/php/build.toml +++ b/packages/registry/php/build.toml @@ -1,7 +1,13 @@ script_path = "packages/registry/php/build-php.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 3 +inputs = [ + "packages/registry/php/build-php.sh", + "packages/registry/php/intl-icu-data-loader.c", +] +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +# Batch 3 extension landings advance this recipe once per byte-changing layer: +# zip (11), curl (12), intl/ICU (13), then truthful unshare detection (14). +revision = 14 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/php/intl-icu-data-loader.c b/packages/registry/php/intl-icu-data-loader.c new file mode 100644 index 0000000000..1f88fb1683 --- /dev/null +++ b/packages/registry/php/intl-icu-data-loader.c @@ -0,0 +1,106 @@ +/* + * Feeds ICU its common data at intl.so load time. + * + * ICU ships as the standalone file icu.dat (see packages/registry/icu), but + * ICU's automatic loader only looks for the conventional icudt.dat + * name and would never find icu.dat on its own. So instead of embedding the + * ~30 MB blob in the .so, we hand it to ICU via udata_setCommonData() from a + * constructor: the side-module loader runs __wasm_call_ctors before PHP calls + * intl's MINIT, so the data is in place before any ICU service touches it. + * + * A missing/unreadable icu.dat is non-fatal at load (intl.so may be present + * without any code using intl) but stays loud: we warn to stderr and let ICU + * fail with U_MISSING_RESOURCE_ERROR when a service actually needs data, rather + * than silently succeeding. The PHP package runtime-file contract installs the + * exact matching bytes at /usr/lib/php/icu.dat on every host. + */ + +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +#define KANDELO_ICU_DAT_PATH "/usr/lib/php/icu.dat" + +static void kandelo_intl_load_icu_data(void) __attribute__((constructor)); + +static void kandelo_intl_load_icu_data(void) { + const char *path = KANDELO_ICU_DAT_PATH; + + int fd = open(path, O_RDONLY); + if (fd < 0) { + fprintf(stderr, + "[intl] ICU data not loaded: cannot open %s. " + "intl functions will fail with U_MISSING_RESOURCE_ERROR. " + "Rebuild/materialize the complete PHP package runtime closure.\n", + path); + return; + } + + struct stat st; + if (fstat(fd, &st) != 0 || st.st_size <= 0) { + fprintf(stderr, "[intl] ICU data not loaded: cannot stat %s.\n", path); + close(fd); + return; + } + + /* + * ICU keeps this pointer for the life of the process, so the buffer must + * outlive this function and is deliberately never freed. A plain read (not + * mmap) sidesteps the VFS's emulated mmap and runs once per process. + */ + size_t size = (size_t) st.st_size; + void *buf = malloc(size); + if (buf == NULL) { + fprintf(stderr, "[intl] ICU data not loaded: OOM reading %s (%zu bytes).\n", + path, size); + close(fd); + return; + } + + size_t off = 0; + while (off < size) { + ssize_t n = read(fd, (char *) buf + off, size - off); + if (n < 0) { + fprintf(stderr, "[intl] ICU data not loaded: read error on %s.\n", path); + free(buf); + close(fd); + return; + } + if (n == 0) break; + off += (size_t) n; + } + close(fd); + + if (off != size) { + fprintf(stderr, "[intl] ICU data not loaded: short read on %s (%zu/%zu).\n", + path, off, size); + free(buf); + return; + } + + UErrorCode status = U_ZERO_ERROR; + udata_setCommonData(buf, &status); + if (U_FAILURE(status)) { + fprintf(stderr, + "[intl] udata_setCommonData(%s) failed: %s. " + "(Likely an ICU library/data version mismatch.)\n", + path, u_errorName(status)); + free(buf); + return; + } + + /* Force ICU to validate/initialize now so version skew surfaces at load. */ + status = U_ZERO_ERROR; + u_init(&status); + if (U_FAILURE(status)) { + fprintf(stderr, "[intl] u_init after loading %s failed: %s.\n", + path, u_errorName(status)); + } +} diff --git a/packages/registry/php/package.toml b/packages/registry/php/package.toml index 709e8699c4..10318ba18a 100644 --- a/packages/registry/php/package.toml +++ b/packages/registry/php/package.toml @@ -1,20 +1,18 @@ kind = "program" name = "php" -version = "8.3.2" -kernel_abi = 7 -depends_on = ["zlib@1.3.1", "openssl@3.3.2", "sqlite@3.49.1", "libxml2@2.13.8"] -# wasm32 only. PHP previously declared wasm64 too (per -# memory/wasm64-build-policy.md), but no demo actually consumes -# `programs/wasm64/php/...` — only mariadb needs the 4 GB address -# space its wasm64 build provides. build-php.sh hard-codes the -# wasm32 SDK toolchain; if a wasm64 PHP build is reintroduced later, -# add `--wasm64` dispatch parallel to mariadb's pattern and stage -# opcache.so under `local-binaries/programs/wasm64/php/`. +version = "8.3.15" +kernel_abi = 27 +# The rebuilt executables import `__wasm_posix_vm_interrupt_after`, which is +# supplied by the host runtime beginning with ABI 27. +depends_on = ["zlib@1.3.1", "openssl@3.3.2", "sqlite@3.49.1", "libxml2@2.13.8", "libiconv@1.17", "libzip@1.11.4", "libcurl@8.11.1", "icu@74.2", "libcxx@21.1.7"] +# wasm32 only: build-php.sh invokes the wasm32 SDK explicitly. If a wasm64 PHP +# build is introduced later, make the recipe architecture-aware and validate +# every CLI, FPM, and extension output before advertising that architecture. arches = ["wasm32"] [source] -url = "https://www.php.net/distributions/php-8.3.2.tar.xz" -sha256 = "4ffa3e44afc9c590e28dc0d2d31fc61f0139f8b335f11880a121b9f9b9f0634e" +url = "https://www.php.net/distributions/php-8.3.15.tar.gz" +sha256 = "67073c3c9c56c86461e0715d9e1806af5ddffe8e6e2eb9781f7923bbb5bd67fa" [license] spdx = "PHP-3.01" @@ -38,13 +36,56 @@ wasm = "php.wasm" name = "php-fpm" wasm = "php-fpm.wasm" -# opcache shipped as a third output: a Zend extension side module -# (.so) that PHP loads via dlopen at MINIT. Demos that want bytecode -# caching point opcache.optimization_level + extension_dir at the -# directory containing this file and pass `-d zend_extension=opcache`. +# opcache shipped as a third output: a Zend extension side module (.so) that +# PHP loads via dlopen at MINIT. Until Kandelo implements cross-process +# MAP_SHARED, the extension deliberately starts only with +# `opcache.file_cache_only=1` and a configured `opcache.file_cache` path; its +# normal shared-memory mode would not keep FPM worker cache/lock state coherent. # build-php.sh links it directly with `wasm32posix-cc -shared` from # PIC-compiled `ext/opcache/.libs/*.o` because PHP's bundled libtool # refuses to emit a shared module on this cross-compile target. [[outputs]] name = "opcache" wasm = "opcache.so" + +# ext/curl is an opt-in shared PHP extension loaded through the standard +# `extension=curl.so` path. curl.so absorbs PIC libcurl code while resolving +# libc, zlib, and OpenSSL against php.wasm at dlopen time. +[[outputs]] +name = "curl" +wasm = "curl.so" + +# Phar ships as a normal shared PHP extension. PHPT tests and real +# applications can opt in with the standard `extension=phar.so` mechanism +# instead of relying on a test-only static module or harness special case. +[[outputs]] +name = "phar" +wasm = "phar.so" + +# zend_test is php-src's own engine-test extension. It is not loaded by +# default, but is available to PHPTs and callers that explicitly request +# `extension=zend_test.so`. +[[outputs]] +name = "zend_test" +wasm = "zend_test.so" + +# ext/zip is shipped as an opt-in shared module backed by the resolver-built +# libzip package. Consumers load it through the normal `extension=zip.so` +# mechanism on Node.js and browser hosts. +[[outputs]] +name = "zip" +wasm = "zip.so" + +# intl is a normal, opt-in PHP extension. It statically absorbs the ICU and +# PIC libc++ archives. PHP copies the exact resolved ICU common-data bytes into +# its own declared runtime closure for VFS consumers. +[[outputs]] +name = "intl" +wasm = "intl.so" + +# Non-Wasm runtime closure for intl. The resolver/archive mirrors this at +# programs//php/icu.dat; VFS consumers install it at the declared guest +# path instead of scanning a library cache or injecting a host-only override. +[[runtime_files]] +artifact = "icu.dat" +guest_path = "/usr/lib/php/icu.dat" diff --git a/packages/registry/php/test/browser/php-browser.spec.ts b/packages/registry/php/test/browser/php-browser.spec.ts index 0414020da4..f0e81012ea 100644 --- a/packages/registry/php/test/browser/php-browser.spec.ts +++ b/packages/registry/php/test/browser/php-browser.spec.ts @@ -8,37 +8,76 @@ * The browser harness runs multiple PHP tests (inline, file-based, extensions) * and reports all results as JSON in the #results element. * - * Run: npx playwright test --config packages/registry/php/test/browser/playwright.config.ts + * Run the default suite: + * npx playwright test --config packages/registry/php/test/browser/playwright.config.ts + * Both cases resolve their complete PHP package closure through the normal + * local/published binary mirrors. */ import { test, expect } from "@playwright/test"; import { existsSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { resolvePackageRuntimeFile } from "../../../../../scripts/package-runtime-file"; +import { tryResolveBinary } from "../../../../../host/src/binary-resolver"; const __dirname = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(__dirname, "../../../../.."); -const hasKernelWasm = [ - join(repoRoot, "local-binaries/kernel.wasm"), - join(repoRoot, "binaries/kernel.wasm"), -].some((candidate) => existsSync(candidate)); -const hasPhpWasm = [ - join(repoRoot, "local-binaries/programs/wasm32/php/php.wasm"), - join(repoRoot, "binaries/programs/wasm32/php/php.wasm"), - join(repoRoot, "packages/registry/php/php-src/sapi/cli/php"), -].some((candidate) => existsSync(candidate)); +const phpIcuRuntime = resolvePackageRuntimeFile(repoRoot, "php", "icu.dat"); +const hasKernelWasm = tryResolveBinary("kernel.wasm") != null; +const hasPhpWasm = phpIcuRuntime?.closureHostPaths.has("php/php.wasm") === true + || existsSync(join(repoRoot, "packages/registry/php/php-src/sapi/cli/php")); +const hasZipSo = phpIcuRuntime?.closureHostPaths.has("php/zip.so") === true + || existsSync(join(repoRoot, "packages/registry/php/bin/zip.so")); +const hasCurlSo = phpIcuRuntime?.closureHostPaths.has("php/curl.so") === true + || existsSync(join(repoRoot, "packages/registry/php/bin/curl.so")); +const hasIntlSo = phpIcuRuntime?.closureHostPaths.has("php/intl.so") === true + || existsSync(join(repoRoot, "packages/registry/php/bin/intl.so")); +if (hasIntlSo && !phpIcuRuntime) { + throw new Error( + "PHP intl.so is present but the declared php:icu.dat runtime file is not materialized", + ); +} +const hasRootfsVfs = tryResolveBinary("rootfs.vfs") != null + || tryResolveBinary("programs/rootfs.vfs") != null; test.skip(!hasKernelWasm, "kernel.wasm is not built or fetched"); test.skip(!hasPhpWasm, "php.wasm is not built or fetched"); +test.skip(!hasRootfsVfs, "rootfs.vfs is not built"); + +test("PHP CLI runs in the browser (inline, file, session, SQLite, fileinfo, XML, OpenSSL, extensions)", async ({ + page, +}) => { + test.skip(!hasZipSo, "zip.so is not built or fetched"); + test.skip(!hasCurlSo, "curl.so is not built or fetched"); + const runtimeErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") runtimeErrors.push(`console: ${msg.text()}`); + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); -test("PHP CLI runs in the browser (inline, file, session, SQLite, fileinfo, XML, extensions)", async ({ page }) => { await page.goto("/"); // Wait for all tests to finish (up to 120s — multiple sequential PHP runs) await page.waitForFunction( () => { const status = document.getElementById("status"); - return status && (status.textContent === "done" || status.textContent === "error"); + return ( + status && + (status.textContent === "done" || status.textContent === "error") + ); }, { timeout: 120_000 }, ); @@ -54,6 +93,8 @@ test("PHP CLI runs in the browser (inline, file, session, SQLite, fileinfo, XML, } expect(status).toBe("done"); + expect(exitCode).toBe("0"); + expect(stderr).toBe(""); const results = JSON.parse(resultsText!); @@ -79,4 +120,71 @@ test("PHP CLI runs in the browser (inline, file, session, SQLite, fileinfo, XML, // SimpleXML expect(results.xml).toContain("xml-ok"); + + // OpenSSL defaults are present in rootfs.vfs and key + CSR generation succeeds. + expect(results.openssl).toContain("openssl-defaults-ok"); + + // Packaged side module and DEFLATE behavior through the browser VFS. + expect(results.zip).toContain("browser-zip-ok"); + + // Packaged curl side module and linked libcurl through browser dlopen. + expect(JSON.parse(results.curl)).toEqual({ + loaded: true, + version: "8.11.1", + constant: true, + handle: true, + }); + expect(JSON.parse(results.curlHttp)).toEqual({ + body: "kandelo-curl-ok\n", + status: 200, + }); + expect(runtimeErrors).toEqual([]); +}); + +test("PHP intl and ICU data survive browser side-module fork replay", async ({ page }) => { + test.skip(!hasIntlSo, "intl.so is not built or fetched"); + + const runtimeErrors: string[] = []; + page.on("console", (msg) => { + if (msg.type() === "error") runtimeErrors.push(`console: ${msg.text()}`); + }); + page.on("pageerror", (error) => { + runtimeErrors.push(`pageerror: ${error.message}`); + }); + page.on("requestfailed", (request) => { + runtimeErrors.push( + `requestfailed: ${request.url()} ${request.failure()?.errorText ?? "failed"}`, + ); + }); + page.on("response", (response) => { + if (response.status() >= 400) { + runtimeErrors.push(`response: ${response.status()} ${response.url()}`); + } + }); + + await page.goto("/?intl=1"); + await page.waitForFunction( + () => { + const status = document.getElementById("status"); + return status?.textContent === "done" || status?.textContent === "error"; + }, + { timeout: 120_000 }, + ); + + const status = await page.locator("#status").textContent(); + const stderr = await page.locator("#stderr").textContent(); + const resultsText = await page.locator("#results").textContent(); + const exitCode = await page.locator("#exit-code").textContent(); + if (status === "error" || exitCode !== "0") { + console.log("INTL STDERR:", stderr); + console.log("INTL RESULTS:", resultsText); + } + + expect(status).toBe("done"); + expect(exitCode).toBe("0"); + expect(stderr).toBe(""); + const results = JSON.parse(resultsText!); + expect(results.intlFork).toContain("child=French"); + expect(results.intlFork).toContain("parent=French:apple,banana,cherry"); + expect(runtimeErrors).toEqual([]); }); diff --git a/packages/registry/php/test/browser/playwright.config.ts b/packages/registry/php/test/browser/playwright.config.ts index 97588e5205..210f834a97 100644 --- a/packages/registry/php/test/browser/playwright.config.ts +++ b/packages/registry/php/test/browser/playwright.config.ts @@ -8,7 +8,7 @@ const phpBrowserTestPort = Number(process.env.PHP_BROWSER_TEST_PORT ?? 5199); export default defineConfig({ testDir: __dirname, testMatch: "php-browser.spec.ts", - timeout: 120_000, + timeout: 180_000, use: { baseURL: `http://127.0.0.1:${phpBrowserTestPort}`, }, diff --git a/packages/registry/php/test/browser/run-php.ts b/packages/registry/php/test/browser/run-php.ts index ac42a13db9..4e026ecc2c 100644 --- a/packages/registry/php/test/browser/run-php.ts +++ b/packages/registry/php/test/browser/run-php.ts @@ -14,6 +14,7 @@ import { writeVfsFile, } from "../../../../../host/src/vfs/image-helpers"; import kernelWasmUrl from "@kernel-wasm?url"; +import rootfsVfsUrl from "@rootfs-vfs?url"; const stdoutEl = document.getElementById("stdout")!; const stderrEl = document.getElementById("stderr")!; @@ -22,6 +23,9 @@ const statusEl = document.getElementById("status")!; const resultsEl = document.getElementById("results")!; const PHP_PATH = "/usr/local/bin/php"; +const PHP_BROWSER_VFS_MAX_BYTES = 256 * 1024 * 1024; +const O_RDONLY = 0; +const VERIFY_CHUNK_BYTES = 64 * 1024; interface TestResult { stdout: string; @@ -29,63 +33,188 @@ interface TestResult { exitCode: number; } +interface BinaryFixture { + bytes: ArrayBuffer; + mode: number; +} + +function assertVfsBinaryRoundTrip( + fs: MemoryFileSystem, + path: string, + expected: Uint8Array, +): void { + const size = fs.stat(path).size; + if (size !== expected.byteLength) { + throw new Error( + `staged VFS file ${path} has ${size} bytes; expected ${expected.byteLength}`, + ); + } + + const fd = fs.open(path, O_RDONLY, 0); + const chunk = new Uint8Array(Math.min(VERIFY_CHUNK_BYTES, expected.byteLength)); + let offset = 0; + try { + while (offset < expected.byteLength) { + const wanted = Math.min(chunk.byteLength, expected.byteLength - offset); + const read = fs.read(fd, chunk, null, wanted); + if (read <= 0) { + throw new Error( + `short VFS verification read for ${path}: ${offset} of ${expected.byteLength}`, + ); + } + for (let i = 0; i < read; i++) { + if (chunk[i] !== expected[offset + i]) { + throw new Error( + `staged VFS file ${path} differs at byte ${offset + i}`, + ); + } + } + offset += read; + } + } finally { + fs.close(fd); + } +} + async function runPhp( phpBytes: ArrayBuffer, kernelBytes: ArrayBuffer, + rootfsBytes: ArrayBuffer, files: Record, argv: string[], + binaryFiles: Record = {}, ): Promise { let stdout = ""; let stderr = ""; const decoder = new TextDecoder(); - const memfs = MemoryFileSystem.create( - new SharedArrayBuffer(16 * 1024 * 1024, { maxByteLength: 64 * 1024 * 1024 }), - 64 * 1024 * 1024, - ); - for (const dir of ["/tmp", "/root", "/dev"]) ensureDir(memfs, dir); + const memfs = MemoryFileSystem.fromImage(new Uint8Array(rootfsBytes), { + maxByteLength: PHP_BROWSER_VFS_MAX_BYTES, + }); + for (const dir of ["/tmp", "/root", "/home", "/dev"]) ensureDir(memfs, dir); memfs.chmod("/tmp", 0o777); memfs.chmod("/root", 0o700); ensureDirRecursive(memfs, "/usr/local/bin"); - writeVfsBinary(memfs, PHP_PATH, new Uint8Array(phpBytes)); + const phpData = new Uint8Array(phpBytes); + writeVfsBinary(memfs, PHP_PATH, phpData); for (const [path, content] of Object.entries(files)) { writeVfsFile(memfs, path, content); } + for (const [path, fixture] of Object.entries(binaryFiles)) { + ensureDirRecursive(memfs, path.slice(0, path.lastIndexOf("/")) || "/"); + const data = new Uint8Array(fixture.bytes); + writeVfsBinary(memfs, path, data, fixture.mode); + assertVfsBinaryRoundTrip(memfs, path, data); + } const vfsImage = await memfs.saveImage(); const kernel = new BrowserKernel({ kernelOwnedFs: true, - maxWorkers: 1, + maxWorkers: 2, onStdout: (data) => { stdout += decoder.decode(data); }, onStderr: (data) => { stderr += decoder.decode(data); }, + onHostDiagnostic: (diagnostic) => { + stderr += `[host:${diagnostic.source} pid=${diagnostic.pid}${diagnostic.status === undefined ? "" : ` status=${diagnostic.status}`}] ${diagnostic.message}\n`; + }, }); - const { exit } = await kernel.boot({ - kernelWasm: kernelBytes, - vfsImage, - argv: [PHP_PATH, ...argv.slice(1)], - env: [ - "HOME=/root", - "TMPDIR=/tmp", - "TERM=xterm-256color", - "USER=root", - "LOGNAME=root", - "PATH=/usr/local/bin:/usr/bin:/bin", - ], - cwd: "/root", - uid: 0, - gid: 0, - }); - const exitCode = await exit; + let exitCode: number; + try { + const { exit } = await kernel.boot({ + kernelWasm: kernelBytes, + vfsImage, + argv: [PHP_PATH, ...argv.slice(1)], + env: [ + "HOME=/root", + "TMPDIR=/tmp", + "TERM=xterm-256color", + "USER=root", + "LOGNAME=root", + "PATH=/usr/local/bin:/usr/bin:/bin", + ], + cwd: "/root", + uid: 0, + gid: 0, + }); + exitCode = await exit; + } finally { + // `exit` is posted before worker teardown completes. Awaiting destroy also + // drains stdout/stderr messages that are still queued behind that signal. + await kernel.destroy(); + } return { stdout, stderr, exitCode }; } async function main() { try { - const [kernelBytes, phpBytes] = await Promise.all([ + const [kernelBytes, rootfsBytes, phpBytes] = await Promise.all([ fetch(kernelWasmUrl).then((r) => r.arrayBuffer()), - fetch("/php.wasm").then((r) => r.arrayBuffer()), + fetch(rootfsVfsUrl).then((r) => r.arrayBuffer()), + fetch("/php-artifacts/php.wasm").then((r) => r.arrayBuffer()), + ]); + + // Run the dedicated intl/fork contract only for its Playwright case. The + // server exposes bytes and installation metadata from PHP's declared + // runtime closure; this browser path does not duplicate the ICU guest path. + if (new URL(window.location.href).searchParams.has("intl")) { + const [intlBytes, icuDataBytes, icuContract] = await Promise.all([ + fetch("/php-artifacts/intl.so").then((r) => r.arrayBuffer()), + fetch("/php-artifacts/icu.dat").then((r) => r.arrayBuffer()), + fetch("/php-runtime-files/icu.dat").then(async (response) => { + if (!response.ok) throw new Error(await response.text()); + return await response.json() as { + artifact: string; + guestPath: string; + mode: number; + }; + }), + ]); + const intlResult = await runPhp( + phpBytes, + kernelBytes, + rootfsBytes, + {}, + ["php", "-n", "-d", "extension_dir=/usr/lib/php/extensions", "-d", "extension=intl.so", "-r", ` + $before = Locale::getDisplayLanguage("fr", "en"); + $pid = pcntl_fork(); + if ($pid < 0) { fwrite(STDERR, "fork-failed"); exit(20); } + if ($pid === 0) { + $child = Locale::getDisplayLanguage("fr", "en"); + echo "child=" . $child . "\\n"; + exit($child === "French" ? 0 : 21); + } + $status = 0; + $waited = pcntl_waitpid($pid, $status); + $c = new Collator("en_US"); + $a = ["banana", "apple", "cherry"]; + $c->sort($a); + echo "parent=" . $before . ":" . implode(",", $a) . "\\n"; + if ($waited !== $pid || !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + fwrite(STDERR, "child-status=" . $status); + exit(22); + } + `], + { + "/usr/lib/php/extensions/intl.so": { bytes: intlBytes, mode: 0o755 }, + [icuContract.guestPath]: { bytes: icuDataBytes, mode: icuContract.mode }, + }, + ); + stdoutEl.textContent = intlResult.stdout; + stderrEl.textContent = intlResult.stderr; + exitCodeEl.textContent = String(intlResult.exitCode); + resultsEl.textContent = JSON.stringify({ intlFork: intlResult.stdout.trim() }); + statusEl.textContent = "done"; + return; + } + + const [zipBytes, curlBytes] = await Promise.all([ + fetch("/php-artifacts/zip.so").then((r) => r.arrayBuffer()), + fetch("/php-artifacts/curl.so").then((r) => r.arrayBuffer()), ]); + const binaryFiles: Record = { + "/usr/lib/php/extensions/zip.so": { bytes: zipBytes, mode: 0o755 }, + "/usr/lib/php/extensions/curl.so": { bytes: curlBytes, mode: 0o755 }, + }; const files = { "/home/script.php": '', @@ -94,32 +223,54 @@ async function main() { }; // Test 1: Hello World (inline) - const r1 = await runPhp(phpBytes, kernelBytes, files, - ["php", "-r", 'echo "Hello World\n";']); + const r1 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-r", 'echo "Hello World\n";'], binaryFiles); // Test 2: File-based execution - const r2 = await runPhp(phpBytes, kernelBytes, files, - ["php", "/home/script.php"]); + const r2 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "/home/script.php"], binaryFiles); // Test 3: Extensions (mbstring + ctype) - const r3 = await runPhp(phpBytes, kernelBytes, files, - ["php", "/home/ext_test.php"]); + const r3 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "/home/ext_test.php"], binaryFiles); // Test 4: Session - const r4 = await runPhp(phpBytes, kernelBytes, files, - ["php", "-r", 'session_start(); echo strlen(session_id()) > 0 ? "session-ok" : "fail";']); + const r4 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-r", 'session_start(); echo strlen(session_id()) > 0 ? "session-ok" : "fail";'], binaryFiles); // Test 5: SQLite3 in-memory - const r5 = await runPhp(phpBytes, kernelBytes, files, - ["php", "-r", '$db=new SQLite3(":memory:");$db->exec("CREATE TABLE t(v TEXT)");$db->exec("INSERT INTO t VALUES(\'sqlite-ok\')");echo $db->querySingle("SELECT v FROM t");']); + const r5 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-r", '$db=new SQLite3(":memory:");$db->exec("CREATE TABLE t(v TEXT)");$db->exec("INSERT INTO t VALUES(\'sqlite-ok\')");echo $db->querySingle("SELECT v FROM t");'], binaryFiles); // Test 6: fileinfo - const r6 = await runPhp(phpBytes, kernelBytes, files, - ["php", "-r", '$f=new finfo(FILEINFO_MIME_TYPE);echo $f->buffer("GIF89a");']); + const r6 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-r", '$f=new finfo(FILEINFO_MIME_TYPE);echo $f->buffer("GIF89a");'], binaryFiles); // Test 7: SimpleXML - const r7 = await runPhp(phpBytes, kernelBytes, files, - ["php", "-r", '$x=new SimpleXMLElement("xml-ok");echo $x->i;']); + const r7 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-r", '$x=new SimpleXMLElement("xml-ok");echo $x->i;'], binaryFiles); + + // Test 8: rootfs OpenSSL defaults are present and key + CSR generation succeeds. + const r8 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-r", '$k=openssl_pkey_new();$c=$k?openssl_csr_new(["commonName"=>"kandelo.test"],$k):false;if(!$k||!$c){while($e=openssl_error_string()){fwrite(STDERR,$e."\\n");}exit(1);}echo "openssl-defaults-ok";'], binaryFiles); + + // Test 9: load the packaged zip side module from the kernel-owned VFS and + // prove that a DEFLATE archive survives close/reopen in the browser host. + const r9 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-n", "-d", "extension_dir=/usr/lib/php/extensions", "-d", "extension=zip.so", "-r", + '$p="/tmp/browser-zip-smoke.zip";$z=new ZipArchive;if($z->open($p,ZipArchive::CREATE|ZipArchive::OVERWRITE)!==true)exit(10);if(!$z->addFromString("hello.txt","browser-zip-ok"))exit(11);if(!$z->setCompressionName("hello.txt",ZipArchive::CM_DEFLATE))exit(12);if(!$z->close())exit(13);$r=new ZipArchive;if($r->open($p)!==true)exit(14);$s=$r->statName("hello.txt");if($s===false||$s["comp_method"]!==ZipArchive::CM_DEFLATE)exit(15);echo $r->getFromName("hello.txt");$r->close();'], binaryFiles); + + // Test 10: load the packaged curl side module from the same browser VFS + // path and call into the linked libcurl implementation. + const r10 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-n", "-d", "extension_dir=/usr/lib/php/extensions", "-d", "extension=curl.so", "-r", + 'echo json_encode(["loaded"=>extension_loaded("curl"),"version"=>curl_version()["version"],"constant"=>defined("CURLOPT_URL"),"handle"=>is_object(curl_init())]);'], binaryFiles); + + // Test 11: exercise browser TCP with libcurl from a fork child. Loading + // curl.so before fork also verifies browser-side dlopen replay. + const r11 = await runPhp(phpBytes, kernelBytes, rootfsBytes, files, + ["php", "-n", "-d", "extension_dir=/usr/lib/php/extensions", "-d", "extension=curl.so", "-r", + '$server=stream_socket_server("tcp://127.0.0.1:0",$errno,$error);if($server===false){fwrite(STDERR,"$errno:$error");exit(10);}$address=stream_socket_get_name($server,false);$pid=pcntl_fork();if($pid<0){fwrite(STDERR,"fork failed");exit(11);}if($pid===0){fclose($server);$ch=curl_init("http://$address/probe");curl_setopt($ch,CURLOPT_RETURNTRANSFER,true);curl_setopt($ch,CURLOPT_TIMEOUT,10);$body=curl_exec($ch);if($body===false){fwrite(STDERR,curl_error($ch));exit(12);}echo json_encode(["body"=>$body,"status"=>curl_getinfo($ch,CURLINFO_RESPONSE_CODE)]);exit(0);}$client=stream_socket_accept($server,10);if($client===false){fwrite(STDERR,"accept failed");exit(13);}$request="";while(!str_contains($request,"\\r\\n\\r\\n")){$chunk=fread($client,4096);if($chunk===false||$chunk===""){fwrite(STDERR,"request read failed");exit(14);}$request.=$chunk;}fwrite($client,"HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nContent-Length: 16\\r\\nConnection: close\\r\\n\\r\\nkandelo-curl-ok\\n");fclose($client);fclose($server);pcntl_waitpid($pid,$status);if(!pcntl_wifexited($status)||pcntl_wexitstatus($status)!==0)exit(15);'], binaryFiles); const results = { hello: r1.stdout.trim(), @@ -129,11 +280,15 @@ async function main() { sqlite: r5.stdout.trim(), fileinfo: r6.stdout.trim(), xml: r7.stdout.trim(), + openssl: r8.stdout.trim(), + zip: r9.stdout.trim(), + curl: r10.stdout.trim(), + curlHttp: r11.stdout.trim(), }; stdoutEl.textContent = r1.stdout; - stderrEl.textContent = [r1.stderr, r2.stderr, r3.stderr, r4.stderr, r5.stderr, r6.stderr, r7.stderr].filter(Boolean).join("\n---\n"); - exitCodeEl.textContent = String(Math.max(r1.exitCode, r2.exitCode, r3.exitCode, r4.exitCode, r5.exitCode, r6.exitCode, r7.exitCode)); + stderrEl.textContent = [r1.stderr, r2.stderr, r3.stderr, r4.stderr, r5.stderr, r6.stderr, r7.stderr, r8.stderr, r9.stderr, r10.stderr, r11.stderr].filter(Boolean).join("\n---\n"); + exitCodeEl.textContent = String(Math.max(r1.exitCode, r2.exitCode, r3.exitCode, r4.exitCode, r5.exitCode, r6.exitCode, r7.exitCode, r8.exitCode, r9.exitCode, r10.exitCode, r11.exitCode)); resultsEl.textContent = JSON.stringify(results); statusEl.textContent = "done"; } catch (e) { diff --git a/packages/registry/php/test/browser/vite.config.ts b/packages/registry/php/test/browser/vite.config.ts index e878480984..87f4aa6887 100644 --- a/packages/registry/php/test/browser/vite.config.ts +++ b/packages/registry/php/test/browser/vite.config.ts @@ -2,10 +2,14 @@ import { fileURLToPath } from "url"; import path from "path"; import fs from "fs"; import type { Plugin } from "vite"; +import { resolvePackageRuntimeFile } from "../../../../../scripts/package-runtime-file"; +import { tryResolveBinary } from "../../../../../host/src/binary-resolver"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const repoRoot = path.resolve(__dirname, "../../../../.."); +const phpIcuRuntime = resolvePackageRuntimeFile(repoRoot, "php", "icu.dat"); + function resolveKernelArtifactsAlias(): Plugin { const KERNEL = "@kernel-wasm"; const ROOTFS = "@rootfs-vfs"; @@ -18,24 +22,21 @@ function resolveKernelArtifactsAlias(): Plugin { const query = queryIdx === -1 ? "" : source.slice(queryIdx); if (pathPart === KERNEL) { - const candidates = [ - path.resolve(repoRoot, "local-binaries/kernel.wasm"), - path.resolve(repoRoot, "binaries/kernel.wasm"), - ]; - const file = candidates.find((candidate) => fs.existsSync(candidate)); + const file = tryResolveBinary("kernel.wasm"); if (file) return file + query; this.error( - "kernel.wasm not found. Run `bash build.sh` from the repo root.\n" + - ` Looked at: ${candidates.join("\n Looked at: ")}`, + "kernel.wasm was not accepted from the standard local, fetched, or packaged locations. " + + "Run `bash build.sh` from the repo root or fetch package binaries.", ); } if (pathPart === ROOTFS) { - const file = path.resolve(repoRoot, "host/wasm/rootfs.vfs"); - if (fs.existsSync(file)) return file + query; + const file = tryResolveBinary("rootfs.vfs") + ?? tryResolveBinary("programs/rootfs.vfs"); + if (file) return file + query; this.error( - "rootfs.vfs not found. Run `bash build.sh` from the repo root.\n" + - ` Looked at: ${file}`, + "rootfs.vfs was not accepted from the standard local, fetched, or packaged locations. " + + "Run `bash build.sh` from the repo root or fetch the rootfs package.", ); } @@ -44,36 +45,87 @@ function resolveKernelArtifactsAlias(): Plugin { }; } -function findPhpBinary(): string | null { +function findPhpArtifact(name: string): string | null { + if (phpIcuRuntime) { + // All PHP artifacts served by this harness come from the single complete + // package tier selected with icu.dat. Never let per-file resolver priority + // compose local and fetched builds. + return phpIcuRuntime.closureHostPaths.get(`php/${name}`) ?? null; + } + if (name === "icu.dat") return null; const candidates = [ - path.resolve(repoRoot, "local-binaries/programs/wasm32/php/php.wasm"), - path.resolve(repoRoot, "binaries/programs/wasm32/php/php.wasm"), - path.resolve(__dirname, "../../php-src/sapi/cli/php"), + path.resolve(__dirname, "../../bin", name), + ...(name === "php.wasm" + ? [path.resolve(__dirname, "../../php-src/sapi/cli/php")] + : []), ]; - return candidates.find((candidate) => fs.existsSync(candidate)) ?? null; + return candidates.find((candidate) => { + try { + return fs.statSync(candidate).isFile(); + } catch { + return false; + } + }) ?? null; } -function servePhpWasm(): Plugin { +function servePhpArtifacts(): Plugin { return { - name: "serve-php-wasm", + name: "serve-php-artifacts", configureServer(server) { server.middlewares.use((req, res, next) => { - if (req.url === "/php.wasm") { - const phpBinary = findPhpBinary(); - if (!phpBinary) { + if (req.url === "/php-runtime-files/icu.dat") { + if (!phpIcuRuntime) { res.statusCode = 404; - res.end( - "php.wasm not found. Run `bash packages/registry/php/build-php.sh` " + - "or fetch package binaries.", - ); + res.end("the declared PHP icu.dat runtime file is not materialized"); return; } - const data = fs.readFileSync(phpBinary); - res.setHeader("Content-Type", "application/wasm"); - res.end(data); + res.setHeader("Content-Type", "application/json"); + res.end(JSON.stringify({ + artifact: phpIcuRuntime.artifact, + guestPath: phpIcuRuntime.guestPath, + mode: phpIcuRuntime.mode, + })); + return; + } + const prefix = "/php-artifacts/"; + if (!req.url?.startsWith(prefix)) { + next(); + return; + } + let name: string; + try { + name = decodeURIComponent(req.url.slice(prefix.length)); + } catch { + res.statusCode = 400; + res.end("invalid percent-encoding in PHP artifact name"); return; } - next(); + if ( + name === "." + || name === ".." + || !/^[A-Za-z0-9._-]+$/.test(name) + ) { + res.statusCode = 400; + res.end("invalid PHP artifact name"); + return; + } + const artifact = findPhpArtifact(name); + if (!artifact) { + res.statusCode = 404; + res.end(name === "icu.dat" + ? "the declared PHP icu.dat runtime file is not materialized" + : `${name} not found. Run \`bash packages/registry/php/build-php.sh\` ` + + "or fetch package binaries."); + return; + } + const data = fs.readFileSync(artifact); + res.setHeader( + "Content-Type", + name.endsWith(".wasm") || name.endsWith(".so") + ? "application/wasm" + : "application/octet-stream", + ); + res.end(data); }); }, }; @@ -81,7 +133,7 @@ function servePhpWasm(): Plugin { export default { root: __dirname, - plugins: [resolveKernelArtifactsAlias(), servePhpWasm()], + plugins: [resolveKernelArtifactsAlias(), servePhpArtifacts()], server: { headers: { // Required for SharedArrayBuffer diff --git a/packages/registry/php/test/php-concurrent-sqlite.test.ts b/packages/registry/php/test/php-concurrent-sqlite.test.ts index af8c1e2b1b..16326dc102 100644 --- a/packages/registry/php/test/php-concurrent-sqlite.test.ts +++ b/packages/registry/php/test/php-concurrent-sqlite.test.ts @@ -3,7 +3,7 @@ * multiple PHP-Wasm processes sharing a SharedLockTable. * * Requires the PHP wasm binary at ../php-src/sapi/cli/php, - * recompiled with channel_syscall.c. + * recompiled with channel_syscall.c for centralized mode. * Skipped if the binary is not present. */ diff --git a/packages/registry/php/test/php-curl.test.ts b/packages/registry/php/test/php-curl.test.ts new file mode 100644 index 0000000000..21320f9a8a --- /dev/null +++ b/packages/registry/php/test/php-curl.test.ts @@ -0,0 +1,176 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { + existsSync, + mkdtempSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { NodePlatformIO } from "../../../../host/src/platform/node"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +const phpBinaryPath = + tryResolveBinary("programs/php/php.wasm") ?? + join(__dirname, "../bin/php.wasm"); +const curlSoPath = + tryResolveBinary("programs/php/curl.so") ?? + join(__dirname, "../bin/curl.so"); + +const READY = existsSync(phpBinaryPath) && existsSync(curlSoPath); +const scratchDirs: string[] = []; + +afterEach(() => { + for (const scratch of scratchDirs.splice(0)) { + rmSync(scratch, { recursive: true, force: true }); + } +}); + +describe.skipIf(!READY)("PHP curl as a runtime-loadable side module", () => { + it("has a closed main-module import set", () => { + const mainModule = new WebAssembly.Module(readFileSync(phpBinaryPath)); + const sideModule = new WebAssembly.Module(readFileSync(curlSoPath)); + const mainExports = new Set( + WebAssembly.Module.exports(mainModule).map(({ name }) => name), + ); + const sideExports = new Set( + WebAssembly.Module.exports(sideModule).map(({ name }) => name), + ); + const loaderImports = new Set([ + "memory", + "__indirect_function_table", + "__stack_pointer", + "__memory_base", + "__table_base", + "__c_longjmp", + ]); + + const missing = WebAssembly.Module.imports(sideModule) + .filter(({ module, name }) => { + if (module === "env") { + return !loaderImports.has(name) && !mainExports.has(name); + } + if (module === "GOT.mem" || module === "GOT.func") { + return !mainExports.has(name) && !sideExports.has(name); + } + return true; + }) + .map(({ module, name, kind }) => `${module}.${name}:${kind}`) + .sort(); + + expect(missing).toEqual([]); + }); + + it("keeps curl out of base php.wasm", async () => { + const { stdout, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-m"], + io: new NodePlatformIO(), + }); + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).not.toContain("curl"); + }, 60_000); + + it("loads curl.so and reports its linked libcurl", async () => { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: [ + "php", + "-d", + `extension=${curlSoPath}`, + "-r", + 'echo json_encode(["loaded" => extension_loaded("curl"), "version" => curl_version()["version"], "constant" => defined("CURLOPT_URL"), "handle" => is_object(curl_init())]);', + ], + io: new NodePlatformIO(), + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + loaded: true, + version: "8.11.1", + constant: true, + handle: true, + }); + }, 60_000); + + it("transfers file URL bytes through libcurl", async () => { + const scratch = mkdtempSync(join(tmpdir(), "kandelo-php-curl-")); + scratchDirs.push(scratch); + const fixture = join(scratch, "fixture.txt"); + writeFileSync(fixture, "kandelo-curl-ok\n"); + const fixtureUrl = pathToFileURL(fixture).href; + + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: [ + "php", + "-d", + `extension=${curlSoPath}`, + "-r", + `$ch = curl_init(${JSON.stringify(fixtureUrl)}); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + $body = curl_exec($ch); + if ($body === false) { fwrite(STDERR, curl_error($ch)); exit(1); } + echo $body;`, + ], + io: new NodePlatformIO(), + timeout: 60_000, + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(stdout).toBe("kandelo-curl-ok\n"); + }, 60_000); + + it("performs HTTP over kernel loopback after fork", async () => { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: [ + "php", + "-d", + `extension=${curlSoPath}`, + "-r", + `$server = stream_socket_server("tcp://127.0.0.1:0", $errno, $error); + if ($server === false) { fwrite(STDERR, "$errno:$error"); exit(10); } + $address = stream_socket_get_name($server, false); + $pid = pcntl_fork(); + if ($pid < 0) { fwrite(STDERR, "fork failed"); exit(11); } + if ($pid === 0) { + fclose($server); + $ch = curl_init("http://$address/probe"); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_TIMEOUT, 10); + $body = curl_exec($ch); + if ($body === false) { fwrite(STDERR, curl_error($ch)); exit(12); } + echo json_encode(["body" => $body, "status" => curl_getinfo($ch, CURLINFO_RESPONSE_CODE)]); + exit(0); + } + $client = stream_socket_accept($server, 10); + if ($client === false) { fwrite(STDERR, "accept failed"); exit(13); } + $request = ""; + while (!str_contains($request, "\\r\\n\\r\\n")) { + $chunk = fread($client, 4096); + if ($chunk === false || $chunk === "") { fwrite(STDERR, "request read failed"); exit(14); } + $request .= $chunk; + } + fwrite($client, "HTTP/1.1 200 OK\\r\\nContent-Type: text/plain\\r\\nContent-Length: 16\\r\\nConnection: close\\r\\n\\r\\nkandelo-curl-ok\\n"); + fclose($client); + fclose($server); + pcntl_waitpid($pid, $status); + if (!pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { exit(15); }`, + ], + io: new NodePlatformIO(), + timeout: 120_000, + }); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + expect(JSON.parse(stdout)).toEqual({ + body: "kandelo-curl-ok\n", + status: 200, + }); + }, 120_000); +}); diff --git a/packages/registry/php/test/php-hello.test.ts b/packages/registry/php/test/php-hello.test.ts index f30cc88203..0ed0ac070c 100644 --- a/packages/registry/php/test/php-hello.test.ts +++ b/packages/registry/php/test/php-hello.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, afterAll } from "vitest"; -import { existsSync, mkdirSync, writeFileSync, rmSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; @@ -15,6 +16,11 @@ const phpBinaryPath = tryResolveBinary("programs/php/php.wasm") ?? join(__dirname, "../php-src/sapi/cli/php"); const PHP_AVAILABLE = existsSync(phpBinaryPath); +const zipSoPath = tryResolveBinary("programs/php/zip.so"); +const ZIP_AVAILABLE = PHP_AVAILABLE && zipSoPath !== null; +const zipExtArgs = ZIP_AVAILABLE + ? ["-n", "-d", `extension_dir=${dirname(zipSoPath!)}`, "-d", "extension=zip.so"] + : []; describe.skipIf(!PHP_AVAILABLE)("PHP CLI on kandelo", () => { it("runs 'echo Hello World' via php -r", async () => { @@ -191,6 +197,27 @@ describe.skipIf(!PHP_AVAILABLE)("PHP zlib + openssl on kandelo", () => { expect(stdout).toContain("openssl-ok"); expect(exitCode).toBe(0); }, 60_000); + + it("exposes rootfs OpenSSL defaults while key and CSR generation succeeds", async () => { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-r", ` + $key = openssl_pkey_new(); + $csr = $key ? openssl_csr_new(["commonName" => "kandelo.test"], $key) : false; + if (!$key || !$csr) { + while ($error = openssl_error_string()) { + fwrite(STDERR, $error . "\\n"); + } + exit(1); + } + echo "openssl-defaults-ok"; + `], + timeout: 120_000, + }); + expect(stderr).toBe(""); + expect(stdout).toContain("openssl-defaults-ok"); + expect(exitCode).toBe(0); + }, 120_000); }); describe.skipIf(!PHP_AVAILABLE)("PHP XML extensions on kandelo", () => { @@ -204,3 +231,49 @@ describe.skipIf(!PHP_AVAILABLE)("PHP XML extensions on kandelo", () => { expect(exitCode).toBe(0); }, 60_000); }); + +describe.skipIf(!ZIP_AVAILABLE)("PHP zip extension on kandelo", () => { + // The package side module lives at a resolver-owned host path. Opt into + // NodePlatformIO for this extension-loading test; the browser companion + // test mounts the same artifact into the kernel-owned VFS. + it("registers ZipArchive through the packaged side module", async () => { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", ...zipExtArgs, "-r", + 'echo class_exists("ZipArchive") ? "ziparchive-ok" : "missing";'], + io: new NodePlatformIO(), + }); + expect(stderr).toBe(""); + expect(stdout).toContain("ziparchive-ok"); + expect(exitCode).toBe(0); + }, 60_000); + + it("round-trips a DEFLATE entry through ZipArchive", async () => { + const scratch = mkdtempSync(join(tmpdir(), "kandelo-php-zip-")); + const archive = join(scratch, "smoke.zip"); + try { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", ...zipExtArgs, "-r", ` + $z = new ZipArchive; + if ($z->open(${JSON.stringify(archive)}, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { exit(10); } + if (!$z->addFromString("hello.txt", "kandelo-zip-ok")) { exit(11); } + if (!$z->setCompressionName("hello.txt", ZipArchive::CM_DEFLATE)) { exit(12); } + if (!$z->close()) { exit(13); } + $r = new ZipArchive; + if ($r->open(${JSON.stringify(archive)}) !== true) { exit(14); } + $stat = $r->statName("hello.txt"); + if ($stat === false || $stat["comp_method"] !== ZipArchive::CM_DEFLATE) { exit(15); } + echo $r->getFromName("hello.txt"); + $r->close(); + `], + io: new NodePlatformIO(), + }); + expect(stderr).toBe(""); + expect(stdout).toContain("kandelo-zip-ok"); + expect(exitCode).toBe(0); + } finally { + rmSync(scratch, { recursive: true, force: true }); + } + }, 60_000); +}); diff --git a/packages/registry/php/test/php-intl.test.ts b/packages/registry/php/test/php-intl.test.ts new file mode 100644 index 0000000000..9999a43486 --- /dev/null +++ b/packages/registry/php/test/php-intl.test.ts @@ -0,0 +1,191 @@ +import { beforeAll, describe, it, expect } from "vitest"; +import { createHash } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; +import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { MemoryFileSystem } from "../../../../host/src/vfs/memory-fs"; +import { + ensureDirRecursive, + writeVfsBinary, +} from "../../../../host/src/vfs/image-helpers"; +import { resolvePackageRuntimeFile } from "../../../../scripts/package-runtime-file"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const icuRuntime = resolvePackageRuntimeFile( + join(__dirname, "../../../.."), + "php", + "icu.dat", +); + +// intl is a RUNTIME-OPTIONAL side module: base php.wasm is built with +// --enable-intl=shared, so intl is NOT compiled in. intl.so is loaded on +// demand via `extension=intl.so`, and pulls its ICU common data from the +// separate icu.dat at runtime (udata_setCommonData in intl-icu-data-loader.c). +const phpBinaryPath = + icuRuntime?.closureHostPaths.get("php/php.wasm") ?? + join(__dirname, "../php-src/sapi/cli/php"); +const intlSoPath = icuRuntime?.closureHostPaths.get("php/intl.so"); +const rootfsPath = + tryResolveBinary("rootfs.vfs") ?? + tryResolveBinary("programs/rootfs.vfs") ?? + join(__dirname, "../../../../host/wasm/rootfs.vfs"); +const INTL_GUEST_PATH = "/usr/lib/php/extensions/intl.so"; +const PHP_INTL_VFS_MAX_BYTES = 256 * 1024 * 1024; +const O_RDONLY = 0; + +if (intlSoPath && !icuRuntime) { + throw new Error( + "PHP intl.so is present but the declared php:icu.dat runtime file is not materialized", + ); +} + +const READY = existsSync(phpBinaryPath) + && intlSoPath != null + && existsSync(rootfsPath); +let intlRootfsImage: Uint8Array; + +function readVfsBinary(fs: MemoryFileSystem, path: string): Uint8Array { + const size = fs.stat(path).size; + const bytes = new Uint8Array(size); + const fd = fs.open(path, O_RDONLY, 0); + let offset = 0; + try { + while (offset < bytes.length) { + const read = fs.read( + fd, + bytes.subarray(offset), + null, + bytes.length - offset, + ); + if (read <= 0) { + throw new Error( + `short VFS read for ${path}: ${offset} of ${bytes.length}`, + ); + } + offset += read; + } + } finally { + fs.close(fd); + } + return bytes; +} + +function sha256(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +describe.skipIf(!READY)("PHP intl as a runtime-loadable side module", () => { + beforeAll(async () => { + const fs = MemoryFileSystem + .fromImage(new Uint8Array(readFileSync(rootfsPath))) + .rebaseToNewFileSystem(PHP_INTL_VFS_MAX_BYTES); + ensureDirRecursive(fs, dirname(INTL_GUEST_PATH)); + ensureDirRecursive(fs, dirname(icuRuntime!.guestPath)); + writeVfsBinary( + fs, + INTL_GUEST_PATH, + new Uint8Array(readFileSync(intlSoPath!)), + 0o755, + ); + const icuBytes = new Uint8Array(readFileSync(icuRuntime!.hostPath)); + writeVfsBinary( + fs, + icuRuntime!.guestPath, + icuBytes, + icuRuntime!.mode, + ); + const stagedIcuBytes = readVfsBinary(fs, icuRuntime!.guestPath); + expect(stagedIcuBytes.byteLength).toBe(icuBytes.byteLength); + expect(sha256(stagedIcuBytes)).toBe(sha256(icuBytes)); + intlRootfsImage = await fs.saveImage(); + }); + + // Proves the base binary is genuinely ICU-free / intl-free: intl only + // appears when explicitly loaded. This is the whole point of the design. + it("base php.wasm does NOT include intl", async () => { + const { stdout, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-m"], + rootfsImage: intlRootfsImage, + }); + expect(exitCode).toBe(0); + expect(stdout.toLowerCase()).not.toContain("intl"); + }, 60_000); + + it("loads intl.so at runtime via extension=", async () => { + const { stdout, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-d", `extension=${INTL_GUEST_PATH}`, "-r", + 'echo extension_loaded("intl") ? "intl-loaded" : "intl-missing";'], + rootfsImage: intlRootfsImage, + }); + expect(stdout).toContain("intl-loaded"); + expect(exitCode).toBe(0); + }, 60_000); + + // Exercises real ICU data (locale display names) to prove icu.dat is + // actually loaded and usable, not just that the module registered. + it("intl uses ICU data (Locale::getDisplayLanguage)", async () => { + const { stdout, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-d", `extension=${INTL_GUEST_PATH}`, "-r", + 'echo Locale::getDisplayLanguage("fr", "en");'], + rootfsImage: intlRootfsImage, + }); + expect(stdout).toContain("French"); + expect(exitCode).toBe(0); + }, 60_000); + + // Collator sorting is a core ICU service that requires collation data. + it("intl Collator sorts with locale rules", async () => { + const { stdout, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-d", `extension=${INTL_GUEST_PATH}`, "-r", ` + $c = new Collator("en_US"); + $a = ["banana", "apple", "cherry"]; + $c->sort($a); + echo implode(",", $a); + `], + rootfsImage: intlRootfsImage, + }); + expect(stdout).toContain("apple,banana,cherry"); + expect(exitCode).toBe(0); + }, 60_000); + + // dlopen occurs before fork. The child therefore replays intl.so without + // rerunning constructors and must retain ICU's common-data pointer from the + // copied process image; exercising ICU in both branches verifies that real + // side-module replay contract rather than only the synthetic linker shape. + it("intl and icu.dat survive pcntl_fork replay", async () => { + const { stdout, stderr, exitCode } = await runCentralizedProgram({ + programPath: phpBinaryPath, + argv: ["php", "-d", `extension=${INTL_GUEST_PATH}`, "-r", ` + $before = Locale::getDisplayLanguage("fr", "en"); + $pid = pcntl_fork(); + if ($pid < 0) { fwrite(STDERR, "fork-failed"); exit(20); } + if ($pid === 0) { + $child = Locale::getDisplayLanguage("fr", "en"); + echo "child=" . $child . "\\n"; + exit($child === "French" ? 0 : 21); + } + $status = 0; + $waited = pcntl_waitpid($pid, $status); + $c = new Collator("en_US"); + $a = ["banana", "apple", "cherry"]; + $c->sort($a); + echo "parent=" . $before . ":" . implode(",", $a) . "\\n"; + if ($waited !== $pid || !pcntl_wifexited($status) || pcntl_wexitstatus($status) !== 0) { + fwrite(STDERR, "child-status=" . $status); + exit(22); + } + `], + rootfsImage: intlRootfsImage, + }); + expect(stderr).toBe(""); + expect(stdout).toContain("child=French"); + expect(stdout).toContain("parent=French:apple,banana,cherry"); + expect(exitCode).toBe(0); + }, 60_000); +}); diff --git a/packages/registry/posix-utils-lite/build-posix-utils-lite.sh b/packages/registry/posix-utils-lite/build-posix-utils-lite.sh index 4c616a45db..cab5c3ef46 100755 --- a/packages/registry/posix-utils-lite/build-posix-utils-lite.sh +++ b/packages/registry/posix-utils-lite/build-posix-utils-lite.sh @@ -10,10 +10,13 @@ SRC="$SCRIPT_DIR/src/posix-utils-lite.c" BIN_DIR="$SCRIPT_DIR/bin" SYSROOT="$REPO_ROOT/sysroot" +# Keep direct and resolver-driven builds pinned to this worktree's SDK. +source "$REPO_ROOT/sdk/activate.sh" + UTILITIES=( ar asa cal cflow compress ctags cxref ed ex fuser gencat getconf gettext iconv ipcrm ipcs lex locale logger man more msgfmt ngettext nm patch pax - ps renice strings strip uncompress uudecode uuencode what xgettext + pgrep ps renice strings strip uncompress uudecode uuencode what xgettext yacc ) diff --git a/packages/registry/posix-utils-lite/build.toml b/packages/registry/posix-utils-lite/build.toml index 22d203eea9..cc85d90f14 100644 --- a/packages/registry/posix-utils-lite/build.toml +++ b/packages/registry/posix-utils-lite/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/posix-utils-lite/build-posix-utils-lite.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "13930e1d2352048372847b6ec6cede8ebb25b5f8" -revision = 2 +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 4 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/posix-utils-lite/package.toml b/packages/registry/posix-utils-lite/package.toml index 00492678de..b4706fc56e 100644 --- a/packages/registry/posix-utils-lite/package.toml +++ b/packages/registry/posix-utils-lite/package.toml @@ -5,12 +5,12 @@ kernel_abi = 7 depends_on = [] [source] -url = "https://github.com/brandonpayton/kandelo" +url = "https://github.com/Automattic/kandelo" sha256 = "0000000000000000000000000000000000000000000000000000000000000000" [license] spdx = "GPL-2.0-or-later" -url = "https://github.com/brandonpayton/kandelo/blob/main/COPYING" +url = "https://github.com/Automattic/kandelo/blob/main/COPYING" [build] script_path = "packages/registry/posix-utils-lite/build-posix-utils-lite.sh" @@ -119,6 +119,10 @@ wasm = "patch.wasm" name = "pax" wasm = "pax.wasm" +[[outputs]] +name = "pgrep" +wasm = "pgrep.wasm" + [[outputs]] name = "ps" wasm = "ps.wasm" diff --git a/packages/registry/posix-utils-lite/src/posix-utils-lite.c b/packages/registry/posix-utils-lite/src/posix-utils-lite.c index 505c975acf..094a4a0065 100644 --- a/packages/registry/posix-utils-lite/src/posix-utils-lite.c +++ b/packages/registry/posix-utils-lite/src/posix-utils-lite.c @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -415,13 +416,276 @@ static int util_logger(int argc, char **argv) { return 0; } +struct ps_fields { + int pid; + int nice; + int command; + int header; +}; + +static int ps_pid_selected(long pid, long *pids, int npids) { + if (npids == 0) { + return 1; + } + for (int i = 0; i < npids; i++) { + if (pids[i] == pid) { + return 1; + } + } + return 0; +} + +static int ps_add_pid_list(const char *arg, long *pids, int *npids, int max_pids) { + if (!arg || !*arg) { + return -1; + } + const char *p = arg; + for (;;) { + if (*npids >= max_pids) { + return -1; + } + errno = 0; + char *end = NULL; + long pid = strtol(p, &end, 10); + if (errno != 0 || end == p || pid <= 0 || (*end != '\0' && *end != ',')) { + return -1; + } + pids[(*npids)++] = pid; + if (*end == '\0') { + return 0; + } + p = end + 1; + if (*p == '\0') { + return -1; + } + } +} + +static int ps_parse_fields(const char *arg, struct ps_fields *fields) { + fields->pid = 0; + fields->nice = 0; + fields->command = 0; + fields->header = 1; + int selected_fields = 0; + int empty_headers = 0; + + char *copy = xstrdup(arg); + for (char *tok = strtok(copy, ", "); tok; tok = strtok(NULL, ", ")) { + char *header = strchr(tok, '='); + if (header) { + // Support the empty-column-header form used by procps, for + // example `ps -o pid=`. Custom non-empty labels are outside this + // compact utility's supported surface. + if (header[1] != '\0') { + free(copy); + return -1; + } + *header = '\0'; + fields->header = 0; + empty_headers++; + } + if (streq(tok, "pid")) { + fields->pid = 1; + } else if (streq(tok, "nice") || streq(tok, "ni")) { + fields->nice = 1; + } else if (streq(tok, "comm") || streq(tok, "command") || + streq(tok, "args")) { + fields->command = 1; + } else { + free(copy); + return -1; + } + selected_fields++; + } + if (!fields->pid && !fields->nice && !fields->command) { + free(copy); + return -1; + } + if (empty_headers > 0 && empty_headers != selected_fields) { + free(copy); + return -1; + } + free(copy); + return 0; +} + +static int pgrep_add_parent_list(const char *arg, long *parents, + int *nparents, int max_parents) { + if (!arg || !*arg) { + return -1; + } + const char *p = arg; + for (;;) { + if (*nparents >= max_parents) { + return -1; + } + errno = 0; + char *end = NULL; + long parent = strtol(p, &end, 10); + if (errno != 0 || end == p || parent < 0 || + (*end != '\0' && *end != ',')) { + return -1; + } + parents[(*nparents)++] = parent; + if (*end == '\0') { + return 0; + } + p = end + 1; + if (*p == '\0') { + return -1; + } + } +} + +static int ps_read_stat(long pid, char *comm, size_t comm_len, long *ppid_value, + long *nice_value) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "/proc/%ld/stat", pid); + FILE *f = fopen(path, "r"); + if (!f) { + return -1; + } + char line[1024]; + if (!fgets(line, sizeof(line), f)) { + fclose(f); + return -1; + } + fclose(f); + + char *open = strchr(line, '('); + char *close = strrchr(line, ')'); + if (!open || !close || close <= open) { + return -1; + } + size_t n = (size_t)(close - open - 1); + if (n >= comm_len) { + n = comm_len - 1; + } + memcpy(comm, open + 1, n); + comm[n] = '\0'; + + // Fields after comm begin with field 3 (state). Nice is field 19, so it + // is token index 16 in this suffix. This follows Linux /proc//stat + // and matches the kernel procfs generator. + char *suffix = close + 2; + char *save = NULL; + int index = 0; + for (char *tok = strtok_r(suffix, " \t\r\n", &save); tok; + tok = strtok_r(NULL, " \t\r\n", &save), index++) { + if (index == 1 || index == 16) { + char *end = NULL; + errno = 0; + long parsed = strtol(tok, &end, 10); + if (errno != 0 || end == tok || *end != '\0') { + return -1; + } + if (index == 1 && ppid_value) { + *ppid_value = parsed; + } else if (index == 16 && nice_value) { + *nice_value = parsed; + } + if (index == 16) { + return 0; + } + } + } + return -1; +} + +static void ps_read_cmdline(long pid, char *cmd, size_t cmd_len) { + char path[PATH_MAX]; + snprintf(path, sizeof(path), "/proc/%ld/cmdline", pid); + FILE *f = fopen(path, "rb"); + cmd[0] = '\0'; + if (!f) { + return; + } + size_t n = fread(cmd, 1, cmd_len - 1, f); + fclose(f); + for (size_t i = 0; i < n; i++) { + if (cmd[i] == '\0') { + cmd[i] = ' '; + } + } + cmd[n] = '\0'; +} + +static void ps_print_header(const struct ps_fields *fields) { + if (!fields->header) { + return; + } + if (fields->pid) { + printf("%5s", "PID"); + } + if (fields->nice) { + printf("%s%4s", fields->pid ? " " : "", "NICE"); + } + if (fields->command) { + printf("%s%s", (fields->pid || fields->nice) ? " " : "", "COMMAND"); + } + putchar('\n'); +} + +static void ps_print_row(const struct ps_fields *fields, long pid, long nice_value, + const char *cmd) { + if (fields->pid) { + printf("%5ld", pid); + } + if (fields->nice) { + printf("%s%4ld", fields->pid ? " " : "", nice_value); + } + if (fields->command) { + printf("%s%s", (fields->pid || fields->nice) ? " " : "", cmd && cmd[0] ? cmd : "?"); + } + putchar('\n'); +} + static int util_ps(int argc, char **argv) { + long selected_pids[64]; + int nselected = 0; + struct ps_fields fields = {1, 0, 1, 1}; + + for (int i = 1; i < argc; i++) { + if (streq(argv[i], "-p") || streq(argv[i], "--pid")) { + if (i + 1 >= argc || + ps_add_pid_list(argv[++i], selected_pids, &nselected, + (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))) != 0) { + fprintf(stderr, "ps: invalid pid list\n"); + return 2; + } + } else if (strncmp(argv[i], "-p", 2) == 0 && argv[i][2]) { + if (ps_add_pid_list(argv[i] + 2, selected_pids, &nselected, + (int)(sizeof(selected_pids) / sizeof(selected_pids[0]))) != 0) { + fprintf(stderr, "ps: invalid pid list\n"); + return 2; + } + } else if (streq(argv[i], "-o") || streq(argv[i], "--format")) { + if (i + 1 >= argc) { + fprintf(stderr, "ps: option requires a format\n"); + return 2; + } + if (ps_parse_fields(argv[++i], &fields) != 0) { + fprintf(stderr, "ps: unsupported format\n"); + return 2; + } + } else if (strncmp(argv[i], "-o", 2) == 0 && argv[i][2]) { + if (ps_parse_fields(argv[i] + 2, &fields) != 0) { + fprintf(stderr, "ps: unsupported format\n"); + return 2; + } + } else if (!streq(argv[i], "-A") && !streq(argv[i], "-e")) { + fprintf(stderr, "ps: unsupported option: %s\n", argv[i]); + return 2; + } + } + DIR *proc = opendir("/proc"); - puts(" PID COMMAND"); if (!proc) { - printf("%5ld %s\n", (long)getpid(), program_name(argv[0])); - return 0; + perror("ps: /proc"); + return 1; } + ps_print_header(&fields); + int matched = 0; struct dirent *de; while ((de = readdir(proc)) != NULL) { char *end = NULL; @@ -429,32 +693,123 @@ static int util_ps(int argc, char **argv) { if (!end || *end != '\0') { continue; } - char path[PATH_MAX]; - snprintf(path, sizeof(path), "/proc/%ld/cmdline", pid); - FILE *f = fopen(path, "rb"); + if (!ps_pid_selected(pid, selected_pids, nselected)) { + continue; + } char cmd[256] = ""; - if (f) { - size_t n = fread(cmd, 1, sizeof(cmd) - 1, f); - fclose(f); - for (size_t i = 0; i < n; i++) { - if (cmd[i] == '\0') { - cmd[i] = ' '; - } - } - cmd[n] = '\0'; + char comm[256] = ""; + long nice_value = 0; + if (ps_read_stat(pid, comm, sizeof(comm), NULL, &nice_value) != 0) { + // The process may have exited between readdir() and fopen(). Do + // not fabricate a row or a nice value for a vanished process. + continue; } if (cmd[0] == '\0') { - snprintf(path, sizeof(path), "/proc/%ld/stat", pid); - f = fopen(path, "r"); - if (f) { - fscanf(f, "%*d (%255[^)])", cmd); - fclose(f); + ps_read_cmdline(pid, cmd, sizeof(cmd)); + } + if (cmd[0] == '\0' && comm[0] != '\0') { + snprintf(cmd, sizeof(cmd), "%s", comm); + } + ps_print_row(&fields, pid, nice_value, cmd); + matched++; + } + closedir(proc); + return nselected > 0 && matched == 0 ? 1 : 0; +} + +static int util_pgrep(int argc, char **argv) { + long selected_parents[64]; + int nselected = 0; + const char *pattern = NULL; + + for (int i = 1; i < argc; i++) { + if (streq(argv[i], "-P") || streq(argv[i], "--parent")) { + if (i + 1 >= argc || + pgrep_add_parent_list(argv[++i], selected_parents, &nselected, + (int)(sizeof(selected_parents) / + sizeof(selected_parents[0]))) != 0) { + fprintf(stderr, "pgrep: invalid parent pid list\n"); + return 2; } + } else if (strncmp(argv[i], "-P", 2) == 0 && argv[i][2]) { + if (pgrep_add_parent_list(argv[i] + 2, selected_parents, + &nselected, + (int)(sizeof(selected_parents) / + sizeof(selected_parents[0]))) != 0) { + fprintf(stderr, "pgrep: invalid parent pid list\n"); + return 2; + } + } else if (argv[i][0] == '-') { + fprintf(stderr, "pgrep: unsupported option: %s\n", argv[i]); + return 2; + } else if (!pattern) { + pattern = argv[i]; + } else { + fprintf(stderr, "pgrep: too many patterns\n"); + return 2; + } + } + + if (nselected == 0 && !pattern) { + fprintf(stderr, "pgrep: usage: pgrep -P parent-list [pattern]\n"); + return 2; + } + + regex_t regex; + int has_regex = 0; + if (pattern) { + int regex_error = regcomp(®ex, pattern, REG_EXTENDED | REG_NOSUB); + if (regex_error != 0) { + char message[256]; + regerror(regex_error, ®ex, message, sizeof(message)); + fprintf(stderr, "pgrep: %s\n", message); + return 2; } - printf("%5ld %s\n", pid, cmd[0] ? cmd : "?"); + has_regex = 1; + } + + DIR *proc = opendir("/proc"); + if (!proc) { + perror("pgrep: /proc"); + if (has_regex) { + regfree(®ex); + } + return 3; + } + + int matched = 0; + struct dirent *de; + while ((de = readdir(proc)) != NULL) { + char *end = NULL; + long pid = strtol(de->d_name, &end, 10); + if (!end || *end != '\0') { + continue; + } + if (pid == (long)getpid()) { + // Like native pgrep, do not report the pgrep process itself when + // its parent happens to match the requested parent filter. + continue; + } + char comm[256] = ""; + long ppid = 0; + if (ps_read_stat(pid, comm, sizeof(comm), &ppid, NULL) != 0) { + continue; + } + if (nselected > 0 && + !ps_pid_selected(ppid, selected_parents, nselected)) { + continue; + } + if (has_regex && regexec(®ex, comm, 0, NULL, 0) != 0) { + continue; + } + printf("%ld\n", pid); + matched = 1; } closedir(proc); - return 0; + if (has_regex) { + regfree(®ex); + } + return matched ? 0 : 1; } static int util_renice(int argc, char **argv) { @@ -1784,6 +2139,7 @@ static int dispatch(const char *name, int argc, char **argv) { if (streq(name, "nm")) return util_nm(argc, argv); if (streq(name, "patch")) return util_patch(argc, argv); if (streq(name, "pax")) return util_pax(argc, argv); + if (streq(name, "pgrep")) return util_pgrep(argc, argv); if (streq(name, "ps")) return util_ps(argc, argv); if (streq(name, "renice")) return util_renice(argc, argv); if (streq(name, "strings")) return util_strings(argc, argv); diff --git a/packages/registry/posix-utils-lite/test/process-tools.test.ts b/packages/registry/posix-utils-lite/test/process-tools.test.ts new file mode 100644 index 0000000000..c4b3d15ab8 --- /dev/null +++ b/packages/registry/posix-utils-lite/test/process-tools.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { tryResolveBinary } from "../../../../host/src/binary-resolver"; +import { runCentralizedProgram } from "../../../../host/test/centralized-test-helper"; + +const dash = tryResolveBinary("programs/dash.wasm"); +const pgrep = tryResolveBinary("programs/posix-utils-lite/pgrep.wasm"); +const ps = tryResolveBinary("programs/posix-utils-lite/ps.wasm"); +const coreutils = tryResolveBinary("programs/coreutils.wasm"); +const artifactsAvailable = !!dash && !!pgrep && !!ps && !!coreutils; + +describe.skipIf(!artifactsAvailable)("posix-utils-lite process tools", () => { + it("reports authoritative child and process state", async () => { + const result = await runCentralizedProgram({ + programPath: dash!, + argv: [ + "dash", + "-c", + [ + "pgrep -P $$", + 'echo "NO_CHILD_RC=$?"', + "sleep 30 & child=$!", + 'echo "CHILD=$child"', + "pgrep -P $$", + 'echo "MATCH_RC=$?"', + 'ps -p "$child" -o pid,nice', + 'echo "PS_RC=$?"', + 'ps -o pid= -p "$child"', + 'echo "PS_NO_HEADER_RC=$?"', + 'ps -p "$child" -o unsupported_field >/dev/null 2>&1', + 'echo "PS_BAD_RC=$?"', + 'kill "$child"', + 'wait "$child" 2>/dev/null', + 'ps -o pid= -p "$child"', + 'echo "REAPED_RC=$?"', + "pgrep --unsupported >/dev/null 2>&1", + 'echo "PGREP_BAD_RC=$?"', + ].join("; "), + ], + env: ["PATH=/bin:/usr/bin", "HOME=/tmp"], + timeout: 30_000, + }); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toBe(""); + const lines = result.stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + expect(lines[0]).toBe("NO_CHILD_RC=1"); + + const child = Number(lines.find((line) => line.startsWith("CHILD="))?.slice(6)); + expect(Number.isInteger(child) && child > 0).toBe(true); + expect(lines).toContain(String(child)); + expect(lines).toContain("MATCH_RC=0"); + expect(lines.some((line) => /^PID\s+NICE$/.test(line))).toBe(true); + expect(lines.some((line) => new RegExp(`^${child}\\s+0$`).test(line))).toBe( + true, + ); + expect(lines).toContain("PS_RC=0"); + expect(lines).toContain("PS_NO_HEADER_RC=0"); + expect(lines).toContain("PS_BAD_RC=2"); + expect(lines).toContain("REAPED_RC=1"); + expect(lines).toContain("PGREP_BAD_RC=2"); + + const childOccurrences = lines.filter((line) => line === String(child)); + // One line from pgrep and one from the headerless ps invocation. The + // reaped-process probe must not fabricate a third row. + expect(childOccurrences).toHaveLength(2); + }, 40_000); +}); diff --git a/packages/registry/python-vfs/build.toml b/packages/registry/python-vfs/build.toml index 41a8c35234..aa2d2379af 100644 --- a/packages/registry/python-vfs/build.toml +++ b/packages/registry/python-vfs/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/python-vfs/build-python-vfs.sh" repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 2 +revision = 3 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/rootfs/build.toml b/packages/registry/rootfs/build.toml index 167c4f984a..1e1ab06f3d 100644 --- a/packages/registry/rootfs/build.toml +++ b/packages/registry/rootfs/build.toml @@ -17,9 +17,9 @@ inputs = [ "host/src/vfs/types.ts", "host/src/vfs/zip.ts", ] -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 4 +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 7 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/shell/build.toml b/packages/registry/shell/build.toml index 833d68a5f8..f0261c1625 100644 --- a/packages/registry/shell/build.toml +++ b/packages/registry/shell/build.toml @@ -24,7 +24,7 @@ inputs = [ ] repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 10 +revision = 11 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/wordpress/build.toml b/packages/registry/wordpress/build.toml index 4b8c21504d..e37037b9c4 100644 --- a/packages/registry/wordpress/build.toml +++ b/packages/registry/wordpress/build.toml @@ -6,6 +6,7 @@ inputs = [ "images/vfs/scripts/build-wp-vfs-image.sh", "images/vfs/scripts/build-wp-vfs-image.ts", "images/vfs/scripts/dinit-image-helpers.ts", + "images/rootfs/etc/services", "images/vfs/scripts/kandelo-demo-config.ts", "images/vfs/scripts/kandelo-demo-guides.ts", "images/vfs/scripts/opcache-prewarm.ts", @@ -28,7 +29,7 @@ inputs = [ ] repo_url = "https://github.com/brandonpayton/kandelo.git" commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 7 +revision = 8 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/wordpress/demo/router.php b/packages/registry/wordpress/demo/router.php index 4ffa8968d7..de5650ffc3 100644 --- a/packages/registry/wordpress/demo/router.php +++ b/packages/registry/wordpress/demo/router.php @@ -5,12 +5,13 @@ * and routing everything else through WordPress's index.php. */ $uri = urldecode(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH)); +$wordpressDir = dirname(__DIR__) . '/wordpress'; // Serve static files directly if they exist -if ($uri !== '/' && file_exists(__DIR__ . '/wordpress' . $uri)) { +if ($uri !== '/' && file_exists($wordpressDir . $uri)) { return false; } // Route all other requests through WordPress -chdir(__DIR__ . '/wordpress'); -require __DIR__ . '/wordpress/index.php'; +chdir($wordpressDir); +require $wordpressDir . '/index.php'; diff --git a/packages/registry/wordpress/demo/serve.ts b/packages/registry/wordpress/demo/serve.ts index a4952496de..216b0fc1ed 100644 --- a/packages/registry/wordpress/demo/serve.ts +++ b/packages/registry/wordpress/demo/serve.ts @@ -71,6 +71,8 @@ async function main() { "-d", "zend_extension=opcache", "-d", "opcache.enable=1", "-d", "opcache.enable_cli=1", + "-d", "opcache.file_cache=/tmp", + "-d", "opcache.file_cache_only=1", "-d", "opcache.memory_consumption=128", "-d", "opcache.validate_timestamps=0", ] : []; diff --git a/packages/registry/wordpress/package.toml b/packages/registry/wordpress/package.toml index d872cb3a4f..6281590bc5 100644 --- a/packages/registry/wordpress/package.toml +++ b/packages/registry/wordpress/package.toml @@ -9,7 +9,7 @@ kernel_abi = 7 depends_on = [ "shell@0.1.0", "nginx@1.24.0", - "php@8.3.2", + "php@8.3.15", "dinit@0.19.4", "msmtpd@1.8.32", ] diff --git a/packages/registry/wordpress/test/wordpress-server.test.ts b/packages/registry/wordpress/test/wordpress-server.test.ts index e02e4a979f..15db221446 100644 --- a/packages/registry/wordpress/test/wordpress-server.test.ts +++ b/packages/registry/wordpress/test/wordpress-server.test.ts @@ -16,7 +16,7 @@ */ import { describe, it, expect } from "vitest"; -import { existsSync, readFileSync } from "node:fs"; +import { existsSync, readFileSync, rmSync } from "node:fs"; import { join, dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { NodeKernelHost } from "../../../../host/src/node-kernel-host"; @@ -28,6 +28,7 @@ const phpBinaryPath = tryResolveBinary("programs/php/php.wasm"); const kernelWasmPath = tryResolveBinary("kernel.wasm"); const wpDir = join(repoRoot, "packages/registry/wordpress/wordpress"); const routerScript = join(repoRoot, "packages/registry/wordpress/demo/router.php"); +const databasePath = join(wpDir, "wp-content/database/wordpress.db"); const SKIP_REASON = !phpBinaryPath ? "PHP binary not built" @@ -45,8 +46,15 @@ function loadFile(path: string): ArrayBuffer { return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); } +function resetWordPressDatabase(): void { + for (const suffix of ["", "-journal", "-shm", "-wal"]) { + rmSync(`${databasePath}${suffix}`, { force: true }); + } +} + describe.skipIf(!!SKIP_REASON)("WordPress HTTP Server (fetchInKernel)", () => { it("serves HTTP requests via fetchInKernel", async () => { + resetWordPressDatabase(); const programBytes = loadFile(phpBinaryPath!); let stderr = ""; @@ -83,9 +91,7 @@ describe.skipIf(!!SKIP_REASON)("WordPress HTTP Server (fetchInKernel)", () => { { timeoutMs: 30_000 }, ); - expect(response.status).not.toBe(504); - expect(response.status).toBeGreaterThanOrEqual(200); - expect(response.status).toBeLessThan(600); + expect(response.status).toBe(302); const installResp = await host.fetchInKernel( KERNEL_PORT, @@ -103,6 +109,7 @@ describe.skipIf(!!SKIP_REASON)("WordPress HTTP Server (fetchInKernel)", () => { expect(html.toLowerCase()).toMatch(/wordpress/); } finally { await host.destroy().catch(() => {}); + resetWordPressDatabase(); } }, 120_000); }); diff --git a/packages/registry/zlib/build-zlib.sh b/packages/registry/zlib/build-zlib.sh old mode 100644 new mode 100755 index 2a5c787139..4dc4dd062d --- a/packages/registry/zlib/build-zlib.sh +++ b/packages/registry/zlib/build-zlib.sh @@ -1,83 +1,93 @@ #!/usr/bin/env bash -# -# Build zlib for wasm32-posix-kernel. -# -# Honors the dep-resolver build-script contract (see -# docs/dependency-management.md). When invoked via -# `cargo xtask build-deps resolve zlib`, these env vars are set by the -# resolver and the build installs into the shared cache: -# -# WASM_POSIX_DEP_OUT_DIR # where to `make install` -# WASM_POSIX_DEP_VERSION # upstream version -# WASM_POSIX_DEP_SOURCE_URL # tarball URL -# WASM_POSIX_DEP_SOURCE_SHA256 # expected sha256 of the tarball -# -# For ad-hoc / legacy invocation (`bash build-zlib.sh` with no resolver), -# the script falls back to the in-tree `zlib-install/` layout. +# Build zlib as an exact, relocatable resolver package. set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -SRC_DIR="$SCRIPT_DIR/zlib-src" +REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" +WORK_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-zlib.XXXXXX")" +trap 'rm -rf "$WORK_DIR"' EXIT +SRC_DIR="$WORK_DIR/source" + +# shellcheck source=/dev/null +source "$REPO_ROOT/sdk/activate.sh" -# --- Inputs from resolver, with legacy fallbacks --- ZLIB_VERSION="${WASM_POSIX_DEP_VERSION:-${ZLIB_VERSION:-1.3.1}}" INSTALL_DIR="${WASM_POSIX_DEP_OUT_DIR:-$SCRIPT_DIR/zlib-install}" SOURCE_URL="${WASM_POSIX_DEP_SOURCE_URL:-https://github.com/madler/zlib/releases/download/v${ZLIB_VERSION}/zlib-${ZLIB_VERSION}.tar.gz}" -SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-}" - -if ! command -v wasm32posix-cc &>/dev/null; then - echo "ERROR: wasm32posix-cc not found. Run 'npm link' in sdk/ first." >&2 - exit 1 -fi +SOURCE_SHA256="${WASM_POSIX_DEP_SOURCE_SHA256:-9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23}" +TARGET_ARCH="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" -# --- Fetch + verify source --- -if [ ! -d "$SRC_DIR" ]; then - echo "==> Downloading zlib $ZLIB_VERSION..." - TARBALL="zlib-${ZLIB_VERSION}.tar.gz" - curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "/tmp/$TARBALL" - if [ -n "$SOURCE_SHA256" ]; then - echo "==> Verifying source sha256..." - echo "$SOURCE_SHA256 /tmp/$TARBALL" | shasum -a 256 -c - - else - echo "==> (no SOURCE_SHA256 declared; skipping verification)" - fi - mkdir -p "$SRC_DIR" - tar xzf "/tmp/$TARBALL" -C "$SRC_DIR" --strip-components=1 - rm "/tmp/$TARBALL" -fi +case "$TARGET_ARCH" in + wasm32) + SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot}" + ;; + wasm64) + SYSROOT="${WASM_POSIX_SYSROOT:-$REPO_ROOT/sysroot64}" + ;; + *) + echo "ERROR: zlib supports wasm32 and wasm64, got $TARGET_ARCH" >&2 + exit 1 + ;; +esac +export WASM_POSIX_SYSROOT="$SYSROOT" -cd "$SRC_DIR" +CC="${TARGET_ARCH}posix-cc" +AR="${TARGET_ARCH}posix-ar" +RANLIB="${TARGET_ARCH}posix-ranlib" +for tool in "$CC" "$AR" "$RANLIB"; do + command -v "$tool" >/dev/null || { + echo "ERROR: $tool not found after sourcing sdk/activate.sh" >&2 + exit 1 + } +done -# `make install` writes into $INSTALL_DIR. Clean first so a cache-miss -# rebuild doesn't mix old + new artifacts inside the resolver's temp dir. -rm -rf "$INSTALL_DIR" +echo "==> Downloading zlib $ZLIB_VERSION..." +TARBALL="$WORK_DIR/zlib.tar.gz" +curl --retry 10 --retry-delay 5 --retry-max-time 300 --retry-all-errors -fsSL "$SOURCE_URL" -o "$TARBALL" +echo "==> Verifying source sha256..." +echo "$SOURCE_SHA256 $TARBALL" | shasum -a 256 -c - +mkdir -p "$SRC_DIR" +tar xzf "$TARBALL" -C "$SRC_DIR" --strip-components=1 -echo "==> Configuring zlib for Wasm..." -CC=wasm32posix-cc AR=wasm32posix-ar RANLIB=wasm32posix-ranlib \ - LDSHARED="wasm32posix-cc -shared" \ - ./configure --static --prefix="$INSTALL_DIR" +cd "$SRC_DIR" +echo "==> Configuring zlib for $TARGET_ARCH..." +CC="$CC" AR="$AR" RANLIB="$RANLIB" \ + LDSHARED="$CC -shared" \ + ./configure --static --prefix=/usr -# On macOS, zlib's configure uses 'libtool' which is the Xcode one — not wasm-aware. -# Patch the Makefile to use wasm32posix-ar instead. -echo "==> Patching Makefile for Wasm ar..." +# On macOS zlib's configure may select Xcode libtool. Pin the SDK archiver. sed -i.bak \ - -e 's|^AR=.*|AR=wasm32posix-ar|' \ + -e "s|^AR=.*|AR=$AR|" \ -e 's|^ARFLAGS=.*|ARFLAGS=rcs|' \ - -e 's|^RANLIB=.*|RANLIB=wasm32posix-ranlib|' \ - -e 's|libtool -o|wasm32posix-ar rcs|g' \ - Makefile && rm -f Makefile.bak + -e "s|^RANLIB=.*|RANLIB=$RANLIB|" \ + -e "s|libtool -o|$AR rcs|g" \ + Makefile +rm -f Makefile.bak echo "==> Building zlib..." make -j"$(sysctl -n hw.ncpu 2>/dev/null || nproc)" libz.a -echo "==> Installing to $INSTALL_DIR..." -make install +echo "==> Staging declared package outputs..." +rm -rf "$INSTALL_DIR" +mkdir -p "$INSTALL_DIR/lib/pkgconfig" "$INSTALL_DIR/include" +cp libz.a "$INSTALL_DIR/lib/" +cp zlib.h zconf.h "$INSTALL_DIR/include/" +cat > "$INSTALL_DIR/lib/pkgconfig/zlib.pc" < zlib build complete!" - ls -lh "$INSTALL_DIR/lib/libz.a" -else - echo "ERROR: Build failed — library not found" >&2 - exit 1 -fi +test -f "$INSTALL_DIR/lib/libz.a" +echo "==> zlib build complete!" +ls -lh "$INSTALL_DIR/lib/libz.a" diff --git a/packages/registry/zlib/build.toml b/packages/registry/zlib/build.toml index 8e4a3dc6d2..5a051064e4 100644 --- a/packages/registry/zlib/build.toml +++ b/packages/registry/zlib/build.toml @@ -1,7 +1,7 @@ script_path = "packages/registry/zlib/build-zlib.sh" -repo_url = "https://github.com/brandonpayton/kandelo.git" -commit = "8c53383229fab78f97b098c3207a655159c03041" -revision = 1 +repo_url = "https://github.com/Automattic/kandelo.git" +commit = "UNPUBLISHED" +revision = 2 [binary] index_url = "https://github.com/Automattic/kandelo/releases/download/binaries-abi-v{abi}/index.toml" diff --git a/packages/registry/zlib/package.toml b/packages/registry/zlib/package.toml index dae0554b7d..54fe3675fd 100644 --- a/packages/registry/zlib/package.toml +++ b/packages/registry/zlib/package.toml @@ -7,8 +7,8 @@ name = "zlib" version = "1.3.1" kernel_abi = 7 depends_on = [] -# Opt into wasm64 because PHP/MariaDB depend on it transitively. -# See memory/wasm64-build-policy.md. +# zlib is one of the low-level packages that opts into both target arches; +# see the `arches` policy in docs/package-management.md. arches = ["wasm32", "wasm64"] [source] diff --git a/programs/ifhwaddr.c b/programs/ifhwaddr.c index e30bab372b..00143f6f22 100644 --- a/programs/ifhwaddr.c +++ b/programs/ifhwaddr.c @@ -1,6 +1,8 @@ -/* Test program: retrieve network interface hardware (MAC) address via ioctl. - * Uses SIOCGIFCONF to enumerate interfaces, then SIOCGIFHWADDR on each. */ +/* Exercise Kandelo's virtual-interface ioctl and libc name/index contracts. */ +#include +#include #include +#include #include #include #include @@ -8,53 +10,278 @@ #include #include -int main(void) { - struct ifreq ifr[8]; - struct ifconf ifc; - int fd, i; +static int failures; - fd = socket(AF_INET, SOCK_DGRAM, 0); - if (fd < 0) { - perror("socket"); - return 1; +static void check(int condition, const char *description) +{ + if (!condition) { + printf("FAIL: %s (errno=%d)\n", description, errno); + failures++; } +} - ifc.ifc_req = ifr; - ifc.ifc_len = sizeof(ifr); +static void set_ifreq_name(struct ifreq *ifr, const char *name) +{ + memset(ifr, 0, sizeof(*ifr)); + strncpy(ifr->ifr_name, name, IF_NAMESIZE - 1); +} - if (ioctl(fd, SIOCGIFCONF, &ifc) < 0) { - perror("SIOCGIFCONF"); - close(fd); - return 1; +static unsigned char *ifreq_ipv4(struct ifreq *ifr) +{ + return (unsigned char *)&ifr->ifr_addr + 4; +} + +static int bytes_equal(const unsigned char *actual, + unsigned char a, unsigned char b, + unsigned char c, unsigned char d) +{ + return actual[0] == a && actual[1] == b && + actual[2] == c && actual[3] == d; +} + +static void check_ifconf(int fd) +{ + struct ifconf ifc = {0}; + struct ifreq interfaces[4]; + + check(ioctl(fd, SIOCGIFCONF, &ifc) == 0, + "SIOCGIFCONF size query succeeds"); + check(ifc.ifc_len == 2 * (int)sizeof(struct ifreq), + "SIOCGIFCONF size query reports two interfaces"); + printf("ifreq-size: %zu\n", sizeof(struct ifreq)); + + struct { + struct ifreq entry; + unsigned char guard[16]; + } bounded; + memset(&bounded, 0, sizeof(bounded)); + memset(bounded.guard, 0xa5, sizeof(bounded.guard)); + ifc.ifc_len = sizeof(bounded.entry); + ifc.ifc_req = &bounded.entry; + check(ioctl(fd, SIOCGIFCONF, &ifc) == 0, + "bounded SIOCGIFCONF succeeds"); + check(ifc.ifc_len == (int)sizeof(struct ifreq), + "bounded SIOCGIFCONF writes one complete entry"); + check(strcmp(bounded.entry.ifr_name, "lo") == 0, + "bounded SIOCGIFCONF returns lo first"); + for (size_t i = 0; i < sizeof(bounded.guard); i++) { + check(bounded.guard[i] == 0xa5, + "bounded SIOCGIFCONF preserves trailing guard bytes"); } - int n = ifc.ifc_len / sizeof(struct ifreq); - printf("interfaces: %d\n", n); + memset(interfaces, 0xcc, sizeof(interfaces)); + ifc.ifc_len = sizeof(interfaces); + ifc.ifc_req = interfaces; + check(ioctl(fd, SIOCGIFCONF, &ifc) == 0, + "full SIOCGIFCONF succeeds"); + int count = ifc.ifc_len / (int)sizeof(struct ifreq); + check(count == 2, "full SIOCGIFCONF returns two interfaces"); + check(strcmp(interfaces[0].ifr_name, "lo") == 0, + "SIOCGIFCONF names loopback lo"); + check(strcmp(interfaces[1].ifr_name, "eth0") == 0, + "SIOCGIFCONF names external interface eth0"); + check(interfaces[0].ifr_addr.sa_family == AF_INET, + "lo SIOCGIFCONF address uses AF_INET"); + check(interfaces[1].ifr_addr.sa_family == AF_INET, + "eth0 SIOCGIFCONF address uses AF_INET"); + check(bytes_equal(ifreq_ipv4(&interfaces[0]), 127, 0, 0, 1), + "lo SIOCGIFCONF address is loopback"); + unsigned char *eth = ifreq_ipv4(&interfaces[1]); + printf("ifconf: lo=127.0.0.1 eth0=%u.%u.%u.%u\n", + eth[0], eth[1], eth[2], eth[3]); - for (i = 0; i < n; i++) { - printf("name: %s\n", ifr[i].ifr_name); + ifc.ifc_len = sizeof(struct ifreq); + ifc.ifc_req = (struct ifreq *)(uintptr_t)-16; + errno = 0; + check(ioctl(fd, SIOCGIFCONF, &ifc) == -1 && errno == EFAULT, + "SIOCGIFCONF rejects an invalid nested buffer"); - if (ioctl(fd, SIOCGIFHWADDR, &ifr[i]) < 0) { - perror("SIOCGIFHWADDR"); - continue; - } + ifc.ifc_len = -1; + ifc.ifc_req = interfaces; + errno = 0; + check(ioctl(fd, SIOCGIFCONF, &ifc) == -1 && errno == EINVAL, + "SIOCGIFCONF rejects a negative length"); - unsigned char *mac = (unsigned char *)ifr[i].ifr_hwaddr.sa_data; - printf("mac: %02x:%02x:%02x:%02x:%02x:%02x\n", - mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + errno = 0; + check(ioctl(fd, SIOCGIFCONF, (void *)(uintptr_t)-16) == -1 && + errno == EFAULT, + "SIOCGIFCONF rejects an invalid outer pointer"); +} - /* Check locally-administered bit */ - if (mac[0] & 0x02) { - printf("locally-administered: yes\n"); - } - /* Check non-zero */ - int all_zero = 1; - for (int j = 0; j < 6; j++) { - if (mac[j]) { all_zero = 0; break; } +static void check_name_index_ioctls(int fd) +{ + struct ifreq ifr; + + set_ifreq_name(&ifr, "lo"); + check(ioctl(fd, SIOCGIFINDEX, &ifr) == 0 && ifr.ifr_ifindex == 1, + "SIOCGIFINDEX maps lo to 1"); + set_ifreq_name(&ifr, "eth0"); + check(ioctl(fd, SIOCGIFINDEX, &ifr) == 0 && ifr.ifr_ifindex == 2, + "SIOCGIFINDEX maps eth0 to 2"); + set_ifreq_name(&ifr, "missing0"); + errno = 0; + check(ioctl(fd, SIOCGIFINDEX, &ifr) == -1 && errno == ENODEV, + "SIOCGIFINDEX rejects an unknown name"); + + memset(&ifr, 0, sizeof(ifr)); + ifr.ifr_ifindex = 1; + check(ioctl(fd, SIOCGIFNAME, &ifr) == 0 && + strcmp(ifr.ifr_name, "lo") == 0, + "SIOCGIFNAME maps 1 to lo"); + memset(&ifr, 0, sizeof(ifr)); + ifr.ifr_ifindex = 2; + check(ioctl(fd, SIOCGIFNAME, &ifr) == 0 && + strcmp(ifr.ifr_name, "eth0") == 0, + "SIOCGIFNAME maps 2 to eth0"); + memset(&ifr, 0, sizeof(ifr)); + ifr.ifr_ifindex = 99; + errno = 0; + check(ioctl(fd, SIOCGIFNAME, &ifr) == -1 && errno == ENODEV, + "SIOCGIFNAME rejects an unknown index"); + + errno = 0; + check(ioctl(fd, SIOCGIFINDEX, (void *)(uintptr_t)-16) == -1 && + errno == EFAULT, + "SIOCGIFINDEX rejects an invalid pointer"); + errno = 0; + check(ioctl(fd, SIOCGIFNAME, (void *)(uintptr_t)-16) == -1 && + errno == EFAULT, + "SIOCGIFNAME rejects an invalid pointer"); +} + +static void check_addresses(int fd) +{ + struct ifreq ifr; + + set_ifreq_name(&ifr, "lo"); + check(ioctl(fd, SIOCGIFADDR, &ifr) == 0, + "SIOCGIFADDR returns lo address"); + check(ifr.ifr_addr.sa_family == AF_INET && + bytes_equal(ifreq_ipv4(&ifr), 127, 0, 0, 1), + "SIOCGIFADDR reports 127.0.0.1 for lo"); + + set_ifreq_name(&ifr, "eth0"); + check(ioctl(fd, SIOCGIFADDR, &ifr) == 0, + "SIOCGIFADDR returns the backend eth0 address"); + check(ifr.ifr_addr.sa_family == AF_INET, + "eth0 SIOCGIFADDR address uses AF_INET"); + unsigned char *address = ifreq_ipv4(&ifr); + printf("eth0-address: %u.%u.%u.%u\n", + address[0], address[1], address[2], address[3]); + + set_ifreq_name(&ifr, "missing0"); + errno = 0; + check(ioctl(fd, SIOCGIFADDR, &ifr) == -1 && errno == ENODEV, + "SIOCGIFADDR rejects an unknown name"); + errno = 0; + check(ioctl(fd, SIOCGIFADDR, (void *)(uintptr_t)-16) == -1 && + errno == EFAULT, + "SIOCGIFADDR rejects an invalid pointer"); +} + +static void check_hardware_addresses(int fd) +{ + struct ifreq ifr; + + set_ifreq_name(&ifr, "lo"); + check(ioctl(fd, SIOCGIFHWADDR, &ifr) == 0, + "SIOCGIFHWADDR returns lo hardware type"); + check(ifr.ifr_hwaddr.sa_family == ARPHRD_LOOPBACK, + "lo uses ARPHRD_LOOPBACK"); + int all_zero = 1; + for (int i = 0; i < 6; i++) { + if ((unsigned char)ifr.ifr_hwaddr.sa_data[i] != 0) all_zero = 0; + } + check(all_zero, "lo hardware address is all zero"); + + set_ifreq_name(&ifr, "eth0"); + check(ioctl(fd, SIOCGIFHWADDR, &ifr) == 0, + "SIOCGIFHWADDR returns eth0 hardware address"); + check(ifr.ifr_hwaddr.sa_family == ARPHRD_ETHER, + "eth0 uses ARPHRD_ETHER"); + unsigned char *mac = (unsigned char *)ifr.ifr_hwaddr.sa_data; + all_zero = 1; + for (int i = 0; i < 6; i++) if (mac[i]) all_zero = 0; + check(!all_zero, "eth0 hardware address is non-zero"); + check((mac[0] & 0x02) != 0 && (mac[0] & 0x01) == 0, + "eth0 hardware address is local unicast"); + printf("eth0-mac: %02x:%02x:%02x:%02x:%02x:%02x\n", + mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + + set_ifreq_name(&ifr, "missing0"); + errno = 0; + check(ioctl(fd, SIOCGIFHWADDR, &ifr) == -1 && errno == ENODEV, + "SIOCGIFHWADDR rejects an unknown name"); + errno = 0; + check(ioctl(fd, SIOCGIFHWADDR, (void *)(uintptr_t)-16) == -1 && + errno == EFAULT, + "SIOCGIFHWADDR rejects an invalid pointer"); +} + +static void check_libc_name_index(void) +{ + errno = 0; + unsigned lo = if_nametoindex("lo"); + unsigned eth0 = if_nametoindex("eth0"); + unsigned missing = if_nametoindex("missing0"); + int missing_errno = errno; + check(lo == 1 && eth0 == 2, "if_nametoindex uses host ioctl mappings"); + check(missing == 0 && missing_errno == ENODEV, + "if_nametoindex rejects an unknown name"); + printf("libc-name-to-index: lo=%u eth0=%u missing=%u errno=%d\n", + lo, eth0, missing, missing_errno); + + char name[IF_NAMESIZE]; + check(if_indextoname(1, name) && strcmp(name, "lo") == 0, + "if_indextoname maps 1 to lo"); + check(if_indextoname(2, name) && strcmp(name, "eth0") == 0, + "if_indextoname maps 2 to eth0"); + errno = 0; + char *invalid = if_indextoname(99, name); + int invalid_errno = errno; + check(!invalid && invalid_errno == ENXIO, + "if_indextoname reports ENXIO for an unknown index"); + printf("libc-invalid-index: errno=%d\n", invalid_errno); + + struct if_nameindex *list = if_nameindex(); + check(list != NULL, "if_nameindex returns an interface list"); + if (list) { + int count = 0; + int saw_lo = 0; + int saw_eth0 = 0; + for (struct if_nameindex *entry = list; entry->if_index; entry++) { + printf("nameindex: %s=%u\n", entry->if_name, entry->if_index); + saw_lo |= entry->if_index == 1 && + strcmp(entry->if_name, "lo") == 0; + saw_eth0 |= entry->if_index == 2 && + strcmp(entry->if_name, "eth0") == 0; + count++; } - printf("non-zero: %s\n", all_zero ? "no" : "yes"); + check(count == 2 && saw_lo && saw_eth0, + "if_nameindex discovers lo and eth0 through ioctls"); + if_freenameindex(list); + } +} + +int main(void) +{ + int fd = socket(AF_INET, SOCK_DGRAM, 0); + if (fd < 0) { + perror("socket"); + return 1; } + check_ifconf(fd); + check_name_index_ioctls(fd); + check_addresses(fd); + check_hardware_addresses(fd); + check_libc_name_index(); + close(fd); + if (failures) { + printf("FAILURES: %d\n", failures); + return 1; + } + printf("PASS: virtual interface ioctl and libc contracts\n"); return 0; } diff --git a/programs/posix-timer-thread.c b/programs/posix-timer-thread.c new file mode 100644 index 0000000000..c0a51142a9 --- /dev/null +++ b/programs/posix-timer-thread.c @@ -0,0 +1,370 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +struct callback_state { + _Atomic int count; + _Atomic int value; + _Atomic uintptr_t thread; + int expected; +}; + +struct wait_state { + _Atomic int ready; + int result; + int error; + long elapsed_ms; + int signum; +}; + +static void timer_callback(union sigval value) +{ + struct callback_state *state = value.sival_ptr; + atomic_store_explicit(&state->value, state->expected, memory_order_relaxed); + atomic_store_explicit( + &state->thread, (uintptr_t)pthread_self(), memory_order_relaxed); + atomic_fetch_add_explicit(&state->count, 1, memory_order_release); +} + +static void noop_callback(union sigval value) +{ + (void)value; +} + +static int arm_thread_timer( + timer_t *timer, struct callback_state *state, int value, long nanoseconds) +{ + struct sigevent event; + struct itimerspec timeout; + + memset(&event, 0, sizeof(event)); + state->expected = value; + event.sigev_notify = SIGEV_THREAD; + event.sigev_notify_function = timer_callback; + event.sigev_value.sival_ptr = state; + + if (timer_create(CLOCK_MONOTONIC, &event, timer) != 0) { + perror("timer_create"); + return -1; + } + + memset(&timeout, 0, sizeof(timeout)); + timeout.it_value.tv_nsec = nanoseconds; + if (timer_settime(*timer, 0, &timeout, NULL) != 0) { + perror("timer_settime"); + timer_delete(*timer); + return -1; + } + + return 0; +} + +static long elapsed_milliseconds( + const struct timespec *start, const struct timespec *end) +{ + return (end->tv_sec - start->tv_sec) * 1000 + + (end->tv_nsec - start->tv_nsec) / 1000000; +} + +static void *finite_signal_wait(void *argument) +{ + struct wait_state *state = argument; + const struct timespec timeout = { + .tv_sec = 1, + .tv_nsec = 200 * 1000 * 1000, + }; + struct timespec start; + struct timespec end; + sigset_t set; + + sigemptyset(&set); + sigaddset(&set, state->signum); + pthread_sigmask(SIG_BLOCK, &set, NULL); + atomic_store_explicit(&state->ready, 1, memory_order_release); + clock_gettime(CLOCK_MONOTONIC, &start); + state->result = sigtimedwait(&set, NULL, &timeout); + state->error = errno; + clock_gettime(CLOCK_MONOTONIC, &end); + state->elapsed_ms = elapsed_milliseconds(&start, &end); + return NULL; +} + +static int test_wait_deadline(void) +{ + const int signum = SIGRTMIN + 2; + const struct timespec settle = {.tv_nsec = 20 * 1000 * 1000}; + struct wait_state first = {.signum = signum}; + struct wait_state second = {.signum = signum}; + struct sigevent event; + struct itimerspec timeout = {0}; + pthread_t first_thread; + pthread_t second_thread; + sigset_t set; + sigset_t old_set; + timer_t timer; + + sigemptyset(&set); + sigaddset(&set, signum); + if (pthread_sigmask(SIG_BLOCK, &set, &old_set) != 0) + return -1; + if (pthread_create(&first_thread, NULL, finite_signal_wait, &first) != 0) { + fputs("pthread_create first finite wait failed\n", stderr); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + if (pthread_create(&second_thread, NULL, finite_signal_wait, &second) != 0) { + fputs("pthread_create second finite wait failed\n", stderr); + pthread_join(first_thread, NULL); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + for (int attempt = 0; attempt < 100; attempt++) { + if (atomic_load_explicit(&first.ready, memory_order_acquire) + && atomic_load_explicit(&second.ready, memory_order_acquire)) + break; + nanosleep(&settle, NULL); + } + nanosleep(&settle, NULL); + + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_SIGNAL; + event.sigev_signo = signum; + if (timer_create(CLOCK_MONOTONIC, &event, &timer) != 0) { + perror("timer_create deadline"); + pthread_join(first_thread, NULL); + pthread_join(second_thread, NULL); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + timeout.it_value.tv_nsec = 400 * 1000 * 1000; + if (timer_settime(timer, 0, &timeout, NULL) != 0) { + perror("timer_settime deadline"); + timer_delete(timer); + pthread_join(first_thread, NULL); + pthread_join(second_thread, NULL); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + pthread_join(first_thread, NULL); + pthread_join(second_thread, NULL); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + + struct wait_state *delivered = first.result == signum ? &first : &second; + struct wait_state *expired = first.result == signum ? &second : &first; + if (delivered->result != signum || expired->result != -1 + || expired->error != EAGAIN || expired->elapsed_ms < 1000 + || expired->elapsed_ms >= 1500) { + fprintf(stderr, + "finite waits: first=%d/%d/%ldms second=%d/%d/%ldms\n", + first.result, first.error, first.elapsed_ms, + second.result, second.error, second.elapsed_ms); + return -1; + } + + printf("WAIT_DEADLINE: delivered=%ldms timeout=%ldms\n", + delivered->elapsed_ms, expired->elapsed_ms); + return 0; +} + +static int test_timer_helper_reclamation(void) +{ + const struct timespec yield = {.tv_nsec = 15 * 1000 * 1000}; + struct sigevent event; + + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_THREAD; + event.sigev_notify_function = noop_callback; + + for (int iteration = 0; iteration < 24; iteration++) { + timer_t timer; + if (timer_create(CLOCK_MONOTONIC, &event, &timer) != 0) { + fprintf(stderr, "timer helper churn failed at %d: %s\n", + iteration, strerror(errno)); + return -1; + } + if (timer_delete(timer) != 0) { + fprintf(stderr, "timer helper delete failed at %d: %s\n", + iteration, strerror(errno)); + return -1; + } + nanosleep(&yield, NULL); + } + + puts("HELPER_CHURN: 24"); + return 0; +} + +static int test_signal_timer(void) +{ + const int signum = SIGRTMIN + 1; + const struct timespec collect_expirations = { + .tv_nsec = 80 * 1000 * 1000, + }; + struct sigevent event; + struct itimerspec schedule; + struct itimerspec disarm = {0}; + sigset_t set; + sigset_t old_set; + siginfo_t info; + timer_t timer; + + sigemptyset(&set); + sigaddset(&set, signum); + if (pthread_sigmask(SIG_BLOCK, &set, &old_set) != 0) { + fputs("pthread_sigmask failed\n", stderr); + return -1; + } + + memset(&event, 0, sizeof(event)); + event.sigev_notify = SIGEV_SIGNAL; + event.sigev_signo = signum; + event.sigev_value.sival_int = 77; + if (timer_create(CLOCK_MONOTONIC, &event, &timer) != 0) { + perror("timer_create SIGEV_SIGNAL"); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + memset(&schedule, 0, sizeof(schedule)); + schedule.it_value.tv_nsec = 5 * 1000 * 1000; + schedule.it_interval.tv_nsec = 5 * 1000 * 1000; + if (timer_settime(timer, 0, &schedule, NULL) != 0) { + perror("timer_settime SIGEV_SIGNAL"); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + nanosleep(&collect_expirations, NULL); + if (timer_settime(timer, 0, &disarm, NULL) != 0) { + perror("timer disarm"); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + memset(&info, 0, sizeof(info)); + if (sigwaitinfo(&set, &info) != signum) { + perror("sigwaitinfo"); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + const int overrun = timer_getoverrun(timer); + const int timer_id = (int)(intptr_t)timer; + if (info.si_code != SI_TIMER || info.si_value.sival_int != 77 + || info.si_timerid != timer_id || info.si_overrun < 1 + || overrun != info.si_overrun) { + fprintf(stderr, + "timer siginfo: code=%d value=%d timer=%d/%d overrun=%d/%d\n", + info.si_code, info.si_value.sival_int, info.si_timerid, timer_id, + info.si_overrun, overrun); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + memset(&schedule, 0, sizeof(schedule)); + schedule.it_value.tv_nsec = 10 * 1000 * 1000; + if (timer_settime(timer, 0, &schedule, NULL) != 0) { + perror("timer rearm"); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + memset(&info, 0, sizeof(info)); + if (sigwaitinfo(&set, &info) != signum || info.si_code != SI_TIMER + || info.si_value.sival_int != 77 || info.si_timerid != timer_id + || info.si_overrun != 0 || timer_getoverrun(timer) != 0) { + fprintf(stderr, "timer overrun did not reset: info=%d current=%d\n", + info.si_overrun, timer_getoverrun(timer)); + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + return -1; + } + + timer_delete(timer); + pthread_sigmask(SIG_SETMASK, &old_set, NULL); + printf("SIGNAL_TIMER: code=%d value=%d timer=%d overrun=%d reset=0\n", + info.si_code, info.si_value.sival_int, info.si_timerid, overrun); + return 0; +} + +int main(void) +{ + struct callback_state first = {0}; + struct callback_state second = {0}; + timer_t first_timer; + timer_t second_timer; + const uintptr_t main_thread = (uintptr_t)pthread_self(); + const struct timespec poll_interval = {.tv_nsec = 5 * 1000 * 1000}; + struct timespec delete_start; + struct timespec delete_end; + + if (arm_thread_timer(&first_timer, &first, 41, 20 * 1000 * 1000) != 0) + return 1; + if (arm_thread_timer(&second_timer, &second, 42, 25 * 1000 * 1000) != 0) { + timer_delete(first_timer); + return 1; + } + + for (int attempt = 0; attempt < 400; attempt++) { + if (atomic_load_explicit(&first.count, memory_order_acquire) == 1 + && atomic_load_explicit(&second.count, memory_order_acquire) == 1) + break; + nanosleep(&poll_interval, NULL); + } + + clock_gettime(CLOCK_MONOTONIC, &delete_start); + if (timer_delete(first_timer) != 0 || timer_delete(second_timer) != 0) { + perror("timer_delete SIGEV_THREAD"); + return 2; + } + clock_gettime(CLOCK_MONOTONIC, &delete_end); + const long delete_ms = elapsed_milliseconds(&delete_start, &delete_end); + + const int first_count = atomic_load_explicit(&first.count, memory_order_acquire); + const int second_count = atomic_load_explicit(&second.count, memory_order_acquire); + const int first_value = atomic_load_explicit(&first.value, memory_order_relaxed); + const int second_value = atomic_load_explicit(&second.value, memory_order_relaxed); + const uintptr_t first_thread = atomic_load_explicit(&first.thread, memory_order_relaxed); + const uintptr_t second_thread = atomic_load_explicit(&second.thread, memory_order_relaxed); + + if (first_count != 1 || second_count != 1) { + fprintf(stderr, "callback counts: first=%d second=%d\n", first_count, second_count); + return 3; + } + if (first_value != 41 || second_value != 42) { + fprintf(stderr, "callback values: first=%d second=%d\n", first_value, second_value); + return 4; + } + if (first_thread == 0 || second_thread == 0 + || first_thread == main_thread || second_thread == main_thread) { + fprintf(stderr, "callbacks did not run on timer helper threads\n"); + return 5; + } + if (delete_ms >= 300) { + fprintf(stderr, "timer_delete helper wake took %ldms\n", delete_ms); + return 6; + } + + printf("CALLBACKS: first=%d/%d second=%d/%d delete=%ldms\n", + first_count, first_value, second_count, second_value, delete_ms); + if (test_signal_timer() != 0) + return 7; + if (test_wait_deadline() != 0) + return 8; + if (test_timer_helper_reclamation() != 0) + return 9; + puts("PASS"); + return 0; +} diff --git a/programs/sched-getaffinity.c b/programs/sched-getaffinity.c new file mode 100644 index 0000000000..2f73f936d4 --- /dev/null +++ b/programs/sched-getaffinity.c @@ -0,0 +1,227 @@ +#define _GNU_SOURCE + +#include +#include +#include +#include +#include +#include +#include +#include + +enum { + KERNEL_MASK_SIZE = 4, + LARGE_MASK_SIZE = 65540, +}; + +static unsigned char large_mask[LARGE_MASK_SIZE]; +static pthread_mutex_t worker_lock = PTHREAD_MUTEX_INITIALIZER; +static pthread_cond_t worker_cond = PTHREAD_COND_INITIALIZER; +static pid_t worker_tid; +static int worker_ready; +static int worker_release; +static int worker_pid_zero_result; + +static int expect_error(pid_t pid, long size, void *mask, int expected, + const char *label) { + errno = 0; + long result = syscall(SYS_sched_getaffinity, pid, size, mask); + if (result != -1 || errno != expected) { + fprintf(stderr, "%s returned %ld errno %d (expected %d)\n", + label, result, errno, expected); + return -1; + } + return 0; +} + +static int expect_unchanged(const unsigned char *mask, size_t size, + const char *label) { + for (size_t i = 0; i < size; ++i) { + if (mask[i] != 0xa5) { + fprintf(stderr, "%s changed byte %zu to 0x%02x\n", + label, i, mask[i]); + return -1; + } + } + return 0; +} + +static int expect_raw_mask(pid_t pid, long size, unsigned char *mask, + size_t storage_size, const char *label) { + memset(mask, 0xa5, storage_size); + errno = 0; + long result = syscall(SYS_sched_getaffinity, pid, size, mask); + if (result != KERNEL_MASK_SIZE) { + fprintf(stderr, "%s returned %ld errno %d\n", label, result, errno); + return -1; + } + if (mask[0] != 1 || mask[1] != 0 || mask[2] != 0 || mask[3] != 0) { + fprintf(stderr, "%s returned mask %02x %02x %02x %02x\n", + label, mask[0], mask[1], mask[2], mask[3]); + return -1; + } + for (size_t i = KERNEL_MASK_SIZE; i < storage_size; ++i) { + if (mask[i] != 0xa5) { + fprintf(stderr, "%s changed raw tail byte %zu to 0x%02x\n", + label, i, mask[i]); + return -1; + } + } + return 0; +} + +static void *affinity_worker(void *unused) { + (void)unused; + cpu_set_t set; + pid_t tid = (pid_t)syscall(SYS_gettid); + int result = expect_raw_mask(0, sizeof(set), (unsigned char *)&set, + sizeof(set), "worker pid zero"); + + pthread_mutex_lock(&worker_lock); + worker_tid = tid; + worker_pid_zero_result = result; + worker_ready = 1; + pthread_cond_signal(&worker_cond); + while (!worker_release) { + pthread_cond_wait(&worker_cond, &worker_lock); + } + pthread_mutex_unlock(&worker_lock); + return NULL; +} + +int main(void) { + cpu_set_t set; + unsigned char *bytes = (unsigned char *)&set; + + const long invalid_sizes[] = { + 0, 1, 3, 5, -1, (long)9007199254740997LL, + }; + const char *invalid_labels[] = { + "zero", "one", "short", "misaligned", "negative", + "wasm64 precision", + }; + for (size_t i = 0; i < sizeof(invalid_sizes) / sizeof(invalid_sizes[0]); ++i) { + memset(&set, 0xa5, sizeof(set)); + if (expect_error(0, invalid_sizes[i], &set, EINVAL, invalid_labels[i]) != 0 + || expect_unchanged(bytes, sizeof(set), invalid_labels[i]) != 0) { + return 1; + } + } + + if (expect_error(0, KERNEL_MASK_SIZE, NULL, EFAULT, "null mask") != 0) { + return 2; + } + if (expect_error(0, KERNEL_MASK_SIZE, (void *)(uintptr_t)-4, + EFAULT, "invalid mask") != 0) { + return 3; + } + if (expect_error(0x7fffffff, 0, NULL, EINVAL, + "invalid size precedence") != 0) { + return 4; + } + + memset(&set, 0xa5, sizeof(set)); + if (expect_error(-1, KERNEL_MASK_SIZE, &set, ESRCH, "negative pid") != 0 + || expect_unchanged(bytes, sizeof(set), "negative pid") != 0) { + return 5; + } + memset(&set, 0xa5, sizeof(set)); + if (expect_error(0x7fffffff, KERNEL_MASK_SIZE, &set, ESRCH, "missing pid") != 0 + || expect_unchanged(bytes, sizeof(set), "missing pid") != 0) { + return 6; + } + if (expect_error(0x7fffffff, KERNEL_MASK_SIZE, NULL, + ESRCH, "missing pid null mask") != 0 + || expect_error(0x7fffffff, KERNEL_MASK_SIZE, + (void *)(uintptr_t)-4, ESRCH, + "missing pid invalid mask") != 0) { + return 7; + } + + if (expect_raw_mask(0, 4, bytes, sizeof(set), "raw size 4") != 0) { + return 8; + } + if (expect_raw_mask(getpid(), 8, bytes, sizeof(set), "raw current pid") != 0) { + return 9; + } + if (expect_raw_mask(0, sizeof(set), bytes, sizeof(set), "raw cpu_set_t") != 0) { + return 10; + } + if (expect_raw_mask(0, (long)-4, bytes, sizeof(set), + "raw unsigned max aligned") != 0) { + return 11; + } + if (expect_raw_mask(0, LARGE_MASK_SIZE, large_mask, sizeof(large_mask), + "raw large aligned") != 0) { + return 12; + } + + pthread_t worker; + if (pthread_create(&worker, NULL, affinity_worker, NULL) != 0) { + fputs("pthread_create failed\n", stderr); + return 13; + } + pthread_mutex_lock(&worker_lock); + while (!worker_ready) { + pthread_cond_wait(&worker_cond, &worker_lock); + } + pid_t live_worker_tid = worker_tid; + int worker_result = worker_pid_zero_result; + pthread_mutex_unlock(&worker_lock); + if (worker_result != 0 || live_worker_tid <= 0) { + fputs("worker pid-zero affinity failed\n", stderr); + return 14; + } + if (expect_raw_mask(live_worker_tid, 8, bytes, sizeof(set), + "live worker tid") != 0) { + return 15; + } + pthread_mutex_lock(&worker_lock); + worker_release = 1; + pthread_cond_signal(&worker_cond); + pthread_mutex_unlock(&worker_lock); + if (pthread_join(worker, NULL) != 0) { + fputs("pthread_join failed\n", stderr); + return 16; + } + memset(&set, 0xa5, sizeof(set)); + if (expect_error(live_worker_tid, KERNEL_MASK_SIZE, &set, + ESRCH, "dead worker tid") != 0 + || expect_unchanged(bytes, sizeof(set), "dead worker tid") != 0) { + return 17; + } + + memset(&set, 0xa5, sizeof(set)); + if (sched_getaffinity(0, sizeof(set), &set) != 0) { + perror("sched_getaffinity"); + return 18; + } + if (!CPU_ISSET(0, &set)) { + fputs("CPU 0 missing\n", stderr); + return 19; + } + for (int cpu = 1; cpu < CPU_SETSIZE; ++cpu) { + if (CPU_ISSET(cpu, &set)) { + fprintf(stderr, "unexpected CPU %d\n", cpu); + return 20; + } + } + for (size_t i = 1; i < sizeof(set); ++i) { + if (bytes[i] != 0) { + fprintf(stderr, "libc left byte %zu as 0x%02x\n", i, bytes[i]); + return 21; + } + } + + long online = sysconf(_SC_NPROCESSORS_ONLN); + long configured = sysconf(_SC_NPROCESSORS_CONF); + if (online != 1 || configured != 1) { + fprintf(stderr, "unexpected CPU counts: online=%ld configured=%ld\n", + online, configured); + return 22; + } + + printf("sched-getaffinity-ok raw=%d cpus=%ld\n", + KERNEL_MASK_SIZE, online); + return 0; +} diff --git a/programs/virtual-tcp-echo-client.c b/programs/virtual-tcp-echo-client.c index 835a08cd7d..e64e2fd834 100644 --- a/programs/virtual-tcp-echo-client.c +++ b/programs/virtual-tcp-echo-client.c @@ -12,12 +12,37 @@ static void die(const char* what) { exit(1); } +static void write_all(int fd, const void* data, size_t size) { + const unsigned char* bytes = data; + size_t offset = 0; + while (offset < size) { + ssize_t amount = write(fd, bytes + offset, size - offset); + if (amount < 0) die("write"); + if (amount == 0) { + fprintf(stderr, "write made no progress\n"); + exit(1); + } + offset += (size_t) amount; + } +} + int main(int argc, char** argv) { - if (argc != 4) { - fprintf(stderr, "usage: %s ADDRESS PORT MESSAGE\n", argv[0]); + if (argc != 4 && argc != 5) { + fprintf(stderr, "usage: %s ADDRESS PORT MESSAGE [POST_FIN_BYTES]\n", argv[0]); return 2; } + size_t post_fin_bytes = 0; + if (argc == 5) { + char* end; + unsigned long value = strtoul(argv[4], &end, 10); + if (!argv[4][0] || *end || value > 16 * 1024 * 1024UL) { + fprintf(stderr, "invalid post-FIN byte count: %s\n", argv[4]); + return 2; + } + post_fin_bytes = (size_t) value; + } + int fd = socket(AF_INET, SOCK_STREAM, 0); if (fd < 0) die("socket"); @@ -30,14 +55,42 @@ int main(int argc, char** argv) { const char* msg = argv[3]; size_t msg_len = strlen(msg); - if (write(fd, msg, msg_len) != (ssize_t) msg_len) die("write"); + write_all(fd, msg, msg_len); char buf[320]; - ssize_t n = read(fd, buf, sizeof(buf) - 1); - if (n < 0) die("read"); - buf[n] = '\0'; + size_t received = 0; + for (;;) { + ssize_t n = read(fd, buf + received, sizeof(buf) - 1 - received); + if (n < 0) die("read"); + if (n == 0) break; + received += (size_t) n; + if (received == sizeof(buf) - 1) { + fprintf(stderr, "reply too large\n"); + return 1; + } + } + buf[received] = '\0'; printf("%s\n", buf); + /* + * A peer FIN closes only its write half. Sending this acknowledgement + * after EOF proves that the server's accepted socket can still receive + * after SHUT_WR and that the host bridge did not collapse the half-close + * into full connection teardown. + */ + if (post_fin_bytes > 0) { + char chunk[16 * 1024]; + memset(chunk, 'x', sizeof(chunk)); + while (post_fin_bytes > 0) { + size_t amount = post_fin_bytes < sizeof(chunk) ? post_fin_bytes : sizeof(chunk); + write_all(fd, chunk, amount); + post_fin_bytes -= amount; + } + } else { + const char ack[] = "ack"; + write_all(fd, ack, sizeof(ack) - 1); + } + close(fd); return 0; } diff --git a/programs/virtual-tcp-echo-server.c b/programs/virtual-tcp-echo-server.c index 2d60445f58..3fda4a538a 100644 --- a/programs/virtual-tcp-echo-server.c +++ b/programs/virtual-tcp-echo-server.c @@ -12,9 +12,46 @@ static void die(const char* what) { exit(1); } +static void write_all(int fd, const void* data, size_t size) { + const unsigned char* bytes = data; + size_t offset = 0; + while (offset < size) { + ssize_t amount = write(fd, bytes + offset, size - offset); + if (amount < 0) die("write"); + if (amount == 0) { + fprintf(stderr, "write made no progress\n"); + exit(1); + } + offset += (size_t) amount; + } +} + int main(int argc, char** argv) { - if (argc != 2) { - fprintf(stderr, "usage: %s PORT\n", argv[0]); + if (argc < 2 || argc > 4) { + fprintf(stderr, "usage: %s PORT [fork | fork-bulk BYTES | half-close | half-close-bulk BYTES | bulk BYTES]\n", argv[0]); + return 2; + } + int test_fork = (argc == 3 && strcmp(argv[2], "fork") == 0) || + (argc == 4 && strcmp(argv[2], "fork-bulk") == 0); + int test_half_close = argc == 3 && strcmp(argv[2], "half-close") == 0; + size_t bulk_bytes = 0; + size_t post_fin_bytes = 0; + if (argc == 4 && + (strcmp(argv[2], "bulk") == 0 || + strcmp(argv[2], "fork-bulk") == 0 || + strcmp(argv[2], "half-close-bulk") == 0)) { + char* end; + unsigned long value = strtoul(argv[3], &end, 10); + if (!argv[3][0] || *end || value > 16 * 1024 * 1024UL) { + fprintf(stderr, "invalid byte count: %s\n", argv[3]); + return 2; + } + if (strcmp(argv[2], "bulk") == 0 || strcmp(argv[2], "fork-bulk") == 0) + bulk_bytes = (size_t) value; + else + post_fin_bytes = (size_t) value; + } else if (argc != 2 && !test_fork && !test_half_close) { + fprintf(stderr, "unknown mode: %s\n", argv[2]); return 2; } @@ -37,6 +74,18 @@ int main(int argc, char** argv) { int conn = accept(fd, (struct sockaddr*) &peer, &peer_len); if (conn < 0) die("accept"); + if (test_fork) { + pid_t child = fork(); + if (child < 0) die("fork"); + if (child > 0) { + close(conn); + close(fd); + return 0; + } + close(fd); + fd = -1; + } + char buf[256]; ssize_t n = read(conn, buf, sizeof(buf)); if (n < 0) die("read"); @@ -47,9 +96,48 @@ int main(int argc, char** argv) { fprintf(stderr, "reply too large\n"); return 1; } - if (write(conn, out, (size_t) out_len) != out_len) die("write"); + write_all(conn, out, (size_t) out_len); + + if (bulk_bytes > 0) { + char chunk[16 * 1024]; + memset(chunk, 'x', sizeof(chunk)); + while (bulk_bytes > 0) { + size_t amount = bulk_bytes < sizeof(chunk) ? bulk_bytes : sizeof(chunk); + write_all(conn, chunk, amount); + bulk_bytes -= amount; + } + } + + if (test_half_close || post_fin_bytes > 0) { + if (shutdown(conn, SHUT_WR) < 0) die("shutdown"); + + /* Let the host-side receive pipe fill before the guest starts reading. */ + if (post_fin_bytes > 0) usleep(200000); + + char ack[16 * 1024]; + size_t expected = post_fin_bytes > 0 ? post_fin_bytes : 3; + size_t received = 0; + while (received < expected) { + size_t remaining = expected - received; + size_t chunk_size = remaining < sizeof(ack) ? remaining : sizeof(ack); + ssize_t amount = read(conn, ack, chunk_size); + if (amount < 0) die("read ack"); + if (amount == 0) { + fprintf(stderr, "EOF before acknowledgement\n"); + return 1; + } + for (ssize_t i = 0; i < amount; i++) { + char expected_byte = post_fin_bytes > 0 ? 'x' : "ack"[received + (size_t) i]; + if (ack[i] != expected_byte) { + fprintf(stderr, "invalid acknowledgement\n"); + return 1; + } + } + received += (size_t) amount; + } + } close(conn); - close(fd); + if (fd >= 0) close(fd); return 0; } diff --git a/run.sh b/run.sh index f01c423261..b7aa1247d4 100755 --- a/run.sh +++ b/run.sh @@ -167,14 +167,17 @@ KERNEL_REQUIRED_EXPORTS=( __abi_version kernel_alloc_scratch kernel_create_process + kernel_create_process_with_stdio kernel_get_parent_pid + kernel_get_process_state kernel_handle_channel + kernel_has_sa_nocldstop kernel_host_adapter_manifest_len kernel_host_adapter_manifest_ptr kernel_mark_process_signaled kernel_reap_exited_child kernel_remove_process - kernel_wait4_poll + kernel_wait_child_poll ) has_valid_kernel_file() { @@ -250,6 +253,9 @@ has_rootfs() { [ -f "$REPO_ROOT/host/wasm/rootfs.vfs" ]; } has_programs() { has_resolvable programs/fork-exec.wasm && has_resolvable programs/fbtest.wasm && + [ -f "$REPO_ROOT/examples/pthread_channel_reuse_test.wasm" ] && + [ -f "$REPO_ROOT/examples/wait_lifecycle_test.wasm" ] && + [ -f "$REPO_ROOT/examples/wait_lifecycle_test.wasm64.wasm" ] && [ -f "$REPO_ROOT/benchmarks/wasm/pipe-throughput.wasm" ] && [ -f "$REPO_ROOT/benchmarks/wasm/file-throughput.wasm" ] && [ -f "$REPO_ROOT/benchmarks/wasm/syscall-latency.wasm" ] && @@ -454,6 +460,8 @@ build_programs() { fi need_kernel need_sysroot + # The owned browser wait-lifecycle fixture exercises the memory64 ABI. + need_sysroot64 if ! has_programs; then step "Building programs" bash "$REPO_ROOT/scripts/build-programs.sh" diff --git a/scripts/build-fork-instrument-tool.sh b/scripts/build-fork-instrument-tool.sh index d03f6f89f2..8f5c5a4a58 100755 --- a/scripts/build-fork-instrument-tool.sh +++ b/scripts/build-fork-instrument-tool.sh @@ -7,7 +7,11 @@ OUT_DIR="$REPO_ROOT/tools/bin" BIN="$OUT_DIR/wasm-fork-instrument" echo "==> Building wasm-fork-instrument for $HOST_TARGET..." -cargo build --release -p fork-instrument --target "$HOST_TARGET" +cargo build \ + --manifest-path "$REPO_ROOT/Cargo.toml" \ + --release \ + -p fork-instrument \ + --target "$HOST_TARGET" mkdir -p "$OUT_DIR" install -m 0755 \ diff --git a/scripts/build-musl.sh b/scripts/build-musl.sh index 421215a9b3..b1da31dbe1 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/" @@ -110,7 +219,7 @@ prefix = $SYSROOT CC = $CC --target=$TARGET AR = $AR RANLIB = $RANLIB -CFLAGS = -O2 -matomics -mbulk-memory -fno-exceptions -fno-trapping-math +CFLAGS = -O2 -matomics -mbulk-memory -mexception-handling -mllvm -wasm-enable-sjlj -mllvm -wasm-use-legacy-eh=false -fno-trapping-math CFLAGS_AUTO = LDFLAGS_AUTO = LIBCC = diff --git a/scripts/build-programs.sh b/scripts/build-programs.sh index 7fe98b59d5..68bbf342a2 100755 --- a/scripts/build-programs.sh +++ b/scripts/build-programs.sh @@ -203,6 +203,12 @@ for src in "$REPO_ROOT/programs/"*.c; do build_program "$src" "$OUT_DIR_32" \ "$SYSROOT/lib/libdrm.a" ;; + posix-timer-thread.c) + # Keep the fixture's pthread capacity small so its timer-helper + # churn test proves detached helpers are actually reclaimed. + build_program "$src" "$OUT_DIR_32" \ + -DWASM_POSIX_THREAD_SLOT_DECL=8 + ;; *) build_program "$src" "$OUT_DIR_32" ;; @@ -264,14 +270,37 @@ if [ -f "$SYSROOT64/lib/libc.a" ]; then -Wl,--export=__tls_align -Wl,--export=__stack_pointer -Wl,--export=__wasm_thread_init + -Wl,--export=__abi_version ) - for src in "$REPO_ROOT/programs/"hello64.c; do + for src in \ + "$REPO_ROOT/programs/"hello64.c \ + "$REPO_ROOT/programs/"ifhwaddr.c \ + "$REPO_ROOT/programs/"posix-timer-thread.c \ + "$REPO_ROOT/programs/"sched-getaffinity.c; do [ -f "$src" ] || continue local_name=$(basename "$src" .c) echo " Compiling $local_name (wasm64)..." - "$CC" "${CFLAGS64[@]}" "$src" "${LINK_FLAGS64[@]}" -o "$OUT_DIR_64/${local_name}.wasm" + extra_flags=() + if [ "$local_name" = "posix-timer-thread" ]; then + extra_flags=(-DWASM_POSIX_THREAD_SLOT_DECL=8) + fi + "$CC" "${CFLAGS64[@]}" "${extra_flags[@]}" "$src" "${LINK_FLAGS64[@]}" \ + -o "$OUT_DIR_64/${local_name}.wasm" done + + # Keep the memory64 wait-lifecycle browser fixture on the same owned build + # path as its wasm32 counterpart. Vitest also compiles this file in global + # setup, but browser-only and packed CI workspaces must not depend on that + # earlier runner having left a generated artifact behind. This fixture + # deliberately uses posix_spawn rather than fork because fork rewind + # instrumentation is currently a wasm32 artifact contract. + wait_lifecycle_src="$REPO_ROOT/examples/wait_lifecycle_test.c" + if [ -f "$wait_lifecycle_src" ]; then + echo " Compiling wait_lifecycle_test (wasm64)..." + "$CC" "${CFLAGS64[@]}" "$wait_lifecycle_src" "${LINK_FLAGS64[@]}" \ + -o "$REPO_ROOT/examples/wait_lifecycle_test.wasm64.wasm" + fi fi echo "Programs built." diff --git a/scripts/check-dev-shell-tools.sh b/scripts/check-dev-shell-tools.sh new file mode 100755 index 0000000000..641fe16155 --- /dev/null +++ b/scripts/check-dev-shell-tools.sh @@ -0,0 +1,26 @@ +#!/usr/bin/env bash + +# Fail when the canonical Nix dev shell resolves declared build tools from the +# ambient host. This is especially important on Darwin, where user-profile, +# /usr/bin, and Homebrew paths can precede mkShell package bins unless the +# shell hook deliberately restores the declared package order. + +set -euo pipefail + +if [ -z "${IN_NIX_SHELL:-}" ]; then + echo "ERROR: check-dev-shell-tools.sh must run through scripts/dev-shell.sh" >&2 + exit 1 +fi + +nix_store="${NIX_STORE:-/nix/store}" +for tool in cmake make; do + resolved="$(command -v "$tool" || true)" + case "$resolved" in + "$nix_store"/*/bin/"$tool") ;; + *) + echo "ERROR: $tool resolved outside the declared Nix tool set: ${resolved:-}" >&2 + exit 1 + ;; + esac + "$tool" --version >/dev/null +done diff --git a/scripts/dev-shell.sh b/scripts/dev-shell.sh index 4ecf73cee7..5c2826bcec 100755 --- a/scripts/dev-shell.sh +++ b/scripts/dev-shell.sh @@ -43,6 +43,18 @@ if [ $# -eq 0 ]; then exit 2 fi +dev_command=("$@") +# A top-level non-interactive login Bash reads /etc/profile after Nix's +# shellHook and can replace the declared PATH with Darwin host defaults. Keep +# the wrapper narrow: only repair the common `bash -lc ` form used by +# repository workflows. Ordinary child shells and package-specific PATH +# prefixes are untouched. +if [ "${dev_command[0]##*/}" = "bash" ] \ + && [ "${dev_command[1]:-}" = "-lc" ] \ + && [ "${#dev_command[@]}" -ge 3 ]; then + dev_command[2]=': "${KANDELO_DEV_SHELL_TOOL_PATH:?missing declared dev-shell tool path}"; export PATH="$KANDELO_DEV_SHELL_TOOL_PATH:$PATH"; '"${dev_command[2]}" +fi + nix_develop=( nix develop --ignore-environment \ @@ -125,7 +137,7 @@ nix_develop=( --keep KANDELO_HOMEBREW_BROWSER_SMOKE_URL \ --keep KANDELO_HOMEBREW_BROWSER_SMOKE_COMMAND \ --accept-flake-config \ - --command "$@" + --command "${dev_command[@]}" ) is_transient_nix_fetch_failure() { diff --git a/scripts/install-local-binary.sh b/scripts/install-local-binary.sh index dfd7a97763..daac62aa00 100755 --- a/scripts/install-local-binary.sh +++ b/scripts/install-local-binary.sh @@ -58,6 +58,14 @@ install_local_binary() { echo "install_local_binary: source file not found: $src" >&2 return 1 fi + local arch="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + case "$arch" in + wasm32|wasm64) ;; + *) + echo "install_local_binary: unsupported target arch '$arch' (expected wasm32 or wasm64)" >&2 + return 2 + ;; + esac # Repo root must be derived from this helper, not from the caller's # current directory: package builds often `cd` into an upstream git @@ -111,8 +119,6 @@ install_local_binary() { ;; esac - local arch="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" - # Take everything from the FIRST dot in the source basename onward # so compound extensions like `.vfs.zst` round-trip intact (matches # the resolver's `place_binaries_symlinks` extension handling). @@ -177,3 +183,56 @@ install_local_binary() { echo " installed $resolver_dest (resolver scratch)" fi } + +# Install a declared non-Wasm `[[runtime_files]]` artifact into the same local +# resolver mirror used by published archives. This is intentionally separate +# from install_local_binary: data files must not pass Wasm/fork guards or be +# described as executable outputs. +install_local_runtime_file() { + local program="$1" + local src="$2" + local artifact="${3:-}" + + if [ -z "$program" ] || [ -z "$src" ]; then + echo "install_local_runtime_file: usage: install_local_runtime_file [artifact]" >&2 + return 2 + fi + if [ ! -f "$src" ] || [ -L "$src" ]; then + echo "install_local_runtime_file: source must be a regular non-symlink file: $src" >&2 + return 1 + fi + local arch="${WASM_POSIX_DEP_TARGET_ARCH:-wasm32}" + case "$arch" in + wasm32|wasm64) ;; + *) + echo "install_local_runtime_file: unsupported target arch '$arch' (expected wasm32 or wasm64)" >&2 + return 2 + ;; + esac + + local repo_root + repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + local host_target + host_target="$(rustc -vV 2>/dev/null | awk '/^host/ {print $2}')" + if [ -z "$host_target" ]; then + echo "install_local_runtime_file: rustc did not report a host target" >&2 + return 1 + fi + local src_basename + src_basename="$(basename "$src")" + artifact="${artifact:-$src_basename}" + local rel + rel="$(cd "$repo_root" && \ + env -u CC -u CXX -u AR -u RANLIB -u CFLAGS -u CXXFLAGS -u CPPFLAGS -u LDFLAGS \ + cargo run -p xtask --target "$host_target" --quiet -- \ + build-deps runtime-file-path "$program" "$artifact")" || return 1 + if [ -z "$rel" ]; then + echo "install_local_runtime_file: manifest lookup returned an empty path" >&2 + return 1 + fi + + local dest="$repo_root/local-binaries/programs/$arch/$rel" + mkdir -p "$(dirname "$dest")" + cp "$src" "$dest" + echo " installed $dest" +} diff --git a/scripts/pack-ci-test-workspace.sh b/scripts/pack-ci-test-workspace.sh index c32778390c..3d7e1e6199 100755 --- a/scripts/pack-ci-test-workspace.sh +++ b/scripts/pack-ci-test-workspace.sh @@ -10,7 +10,13 @@ if [ -z "$out" ]; then exit 2 fi -for required in local-binaries/kernel.wasm host/wasm/rootfs.vfs examples/gencat.wasm; do +for required in \ + local-binaries/kernel.wasm \ + host/wasm/rootfs.vfs \ + examples/gencat.wasm \ + examples/pthread_channel_reuse_test.wasm \ + examples/wait_lifecycle_test.wasm \ + examples/wait_lifecycle_test.wasm64.wasm; do if [ ! -f "$required" ]; then echo "pack-ci-test-workspace: missing required artifact: $required" >&2 exit 1 diff --git a/scripts/package-runtime-file.ts b/scripts/package-runtime-file.ts new file mode 100644 index 0000000000..2045a36b44 --- /dev/null +++ b/scripts/package-runtime-file.ts @@ -0,0 +1,147 @@ +/** + * Repo-side bridge from package.toml `[[runtime_files]]` to VFS/test builders. + * + * Runtime-file metadata is a build/materialization contract, not a host-runtime + * API: published browser/rootfs images contain the installed bytes already. + * Repo tools query xtask so guest paths and modes are never duplicated in + * TypeScript fixtures. + */ +import { execFileSync } from "node:child_process"; +import { isAbsolute } from "node:path"; +import { tryResolveBinarySet } from "../host/src/binary-resolver"; + +export interface PackageRuntimeFileContract { + artifact: string; + guestPath: string; + mode: number; + mirrorPath: string; + /** Every program output + runtime file declared by this package. */ + closureMirrorPaths: string[]; +} + +export interface ResolvedPackageRuntimeFile extends PackageRuntimeFileContract { + hostPath: string; + /** Host paths keyed by resolver mirror path, all from one provenance root. */ + closureHostPaths: ReadonlyMap; +} + +let cachedHostTarget: string | undefined; + +function hostTarget(): string { + if (cachedHostTarget) return cachedHostTarget; + const output = execFileSync("rustc", ["-vV"], { encoding: "utf8" }); + const target = output.match(/^host:\s*(\S+)$/m)?.[1]; + if (!target) throw new Error("rustc -vV did not report a host target"); + cachedHostTarget = target; + return target; +} + +function hostCargoEnv(): NodeJS.ProcessEnv { + const env = { ...process.env }; + for (const name of [ + "CC", + "CXX", + "AR", + "RANLIB", + "CFLAGS", + "CXXFLAGS", + "CPPFLAGS", + "LDFLAGS", + ]) { + delete env[name]; + } + return env; +} + +export function readPackageRuntimeFileContract( + repoRoot: string, + packageName: string, + artifact: string, +): PackageRuntimeFileContract { + const raw = execFileSync( + "cargo", + [ + "run", + "-p", + "xtask", + "--target", + hostTarget(), + "--quiet", + "--", + "build-deps", + "runtime-file-metadata", + packageName, + artifact, + ], + { cwd: repoRoot, encoding: "utf8", env: hostCargoEnv() }, + ).trim(); + return parsePackageRuntimeFileContract(raw, packageName, artifact); +} + +/** Parse and validate xtask's structured runtime-file metadata. */ +export function parsePackageRuntimeFileContract( + raw: string, + packageName: string, + artifact: string, +): PackageRuntimeFileContract { + const parsed = JSON.parse(raw) as Record; + const contract: PackageRuntimeFileContract = { + artifact: parsed.artifact as string, + guestPath: parsed.guest_path as string, + mode: parsed.mode as number, + mirrorPath: parsed.mirror_path as string, + closureMirrorPaths: parsed.closure_mirror_paths as string[], + }; + const validMirrorPath = (value: unknown): value is string => + typeof value === "string" + && !isAbsolute(value) + && !value.includes("\\") + && !value.includes("\0") + && value + .split("/") + .every((part) => Boolean(part) && part !== "." && part !== ".."); + if ( + contract.artifact !== artifact + || typeof contract.guestPath !== "string" + || !contract.guestPath.startsWith("/") + || !Number.isInteger(contract.mode) + || contract.mode < 0 + || contract.mode > 0o777 + || !validMirrorPath(contract.mirrorPath) + || !Array.isArray(contract.closureMirrorPaths) + || contract.closureMirrorPaths.length === 0 + || !contract.closureMirrorPaths.every(validMirrorPath) + || new Set(contract.closureMirrorPaths).size !== contract.closureMirrorPaths.length + || !contract.closureMirrorPaths.includes(contract.mirrorPath) + ) { + throw new Error( + `invalid runtime-file metadata for ${packageName}:${artifact}: ${raw}`, + ); + } + return contract; +} + +export function resolvePackageRuntimeFile( + repoRoot: string, + packageName: string, + artifact: string, +): ResolvedPackageRuntimeFile | undefined { + const contract = readPackageRuntimeFileContract(repoRoot, packageName, artifact); + const hostPaths = tryResolveBinarySet( + contract.closureMirrorPaths.map((mirrorPath) => `programs/${mirrorPath}`), + ); + if (!hostPaths) return undefined; + const closureHostPaths = new Map( + contract.closureMirrorPaths.map((mirrorPath, index) => [ + mirrorPath, + hostPaths[index], + ]), + ); + const hostPath = closureHostPaths.get(contract.mirrorPath); + if (!hostPath) { + throw new Error( + `resolved package closure omitted ${packageName}:${contract.mirrorPath}`, + ); + } + return { ...contract, hostPath, closureHostPaths }; +} diff --git a/packages/registry/openssl/fetch-cacert.sh b/scripts/refresh-rootfs-cacert.sh similarity index 68% rename from packages/registry/openssl/fetch-cacert.sh rename to scripts/refresh-rootfs-cacert.sh index a01c0d4edc..d57f5abd3a 100755 --- a/packages/registry/openssl/fetch-cacert.sh +++ b/scripts/refresh-rootfs-cacert.sh @@ -1,14 +1,15 @@ #!/usr/bin/env bash -# Refresh packages/registry/openssl/cacert.pem from curl.se's mirror of Mozilla's -# root CA bundle. Run manually when bumping the bundle. The fetched bytes are -# checked in; the kernel embeds them via include_bytes! and serves them at -# /etc/ssl/cert.pem to wasm processes (see crates/kernel/src/syscalls.rs). +# Refresh the canonical rootfs CA bundle from curl.se's mirror of Mozilla's +# root certificates. Run manually when bumping the bundle. The fetched bytes +# are checked in and scripts/build-rootfs.sh installs them at OpenSSL's default +# /etc/ssl/cert.pem path and the conventional ca-certificates.crt path. # # Source upstream: https://curl.se/docs/caextract.html set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -DEST="$SCRIPT_DIR/cacert.pem" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEST="$REPO_ROOT/images/rootfs/etc/ssl/cert.pem" URL="https://curl.se/ca/cacert.pem" echo "==> Fetching $URL" diff --git a/scripts/resolve-binary.sh b/scripts/resolve-binary.sh index 9471a66852..978bc8c60c 100755 --- a/scripts/resolve-binary.sh +++ b/scripts/resolve-binary.sh @@ -151,14 +151,17 @@ kernel_required_exports=( __abi_version kernel_alloc_scratch kernel_create_process + kernel_create_process_with_stdio kernel_get_parent_pid + kernel_get_process_state kernel_handle_channel + kernel_has_sa_nocldstop kernel_host_adapter_manifest_len kernel_host_adapter_manifest_ptr kernel_mark_process_signaled kernel_reap_exited_child kernel_remove_process - kernel_wait4_poll + kernel_wait_child_poll ) executable_program_required_exports=( 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-libc-tests.sh b/scripts/run-libc-tests.sh index a6b5749aef..53a50423be 100755 --- a/scripts/run-libc-tests.sh +++ b/scripts/run-libc-tests.sh @@ -25,8 +25,7 @@ KERNEL_WASM="$("$REPO_ROOT/scripts/resolve-binary.sh" kernel.wasm)" MATH_EXPECTED_FAIL=(acosh asinh erfc j0 jn jnf lgamma lgammaf lgammaf_r sinh tgamma y0 y0f ynf) MATH_RELAXED_EXPECTED_FAIL=(tgamma j0 y0 y0f) # Tests with inline checks that bypass checkulp -# Tests blocked by fundamental Wasm limitations (no cancel-point asm, opaque stack, -# no file-backed mmap for sem_open). +# Tests blocked by fundamental Wasm limitations (no cancellation-point asm). FUNCTIONAL_EXPECTED_FAIL=( pthread_cancel # no cancel-point asm (__syscall_cp_asm) for Wasm ) diff --git a/scripts/run-php-upstream-node-chunks.sh b/scripts/run-php-upstream-node-chunks.sh new file mode 100755 index 0000000000..efab5bc5fb --- /dev/null +++ b/scripts/run-php-upstream-node-chunks.sh @@ -0,0 +1,292 @@ +#!/usr/bin/env bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +PHP_SRC="${PHP_SOURCE_DIR:-}" + +host="${PHP_TEST_HOST:-node}" +chunk_size="${PHP_TEST_CHUNK_SIZE:-500}" +jobs="${PHP_TEST_JOBS:-}" +timeout_ms="${PHP_TEST_TIMEOUT_MS:-600000}" +host_reset_interval="${PHP_TEST_HOST_RESET_INTERVAL:-25}" +run_uid="${PHP_TEST_RUN_UID:-}" +run_gid="${PHP_TEST_RUN_GID:-}" +start_offset=0 +out_dir="" +force=0 +summary_only=0 +rebuild_vfs=0 + +die() { + echo "run-php-upstream-node-chunks: $*" >&2 + exit 2 +} + +usage() { + cat <<'USAGE' +Usage: scripts/run-php-upstream-node-chunks.sh [options] + +Run the full php-src PHPT suite on a Kandelo host in restartable chunks. +Each chunk invokes scripts/run-php-upstream-tests.sh in a fresh Node.js process. +This prevents long monolithic Node-host runs from accumulating host/Wasm memory +and gives browser-host runs resumable checkpoints. Unsupported harness features +remain explicit in the summary but do not stop this inventory-oriented wrapper. + +Options: + --host Kandelo host to run (default: PHP_TEST_HOST or node) + --chunk-size Tests per chunk (default: PHP_TEST_CHUNK_SIZE or 500) + --jobs PHPT concurrency (default: PHP_TEST_JOBS, else Node 4 / browser 1) + --timeout Per PHPT section timeout (default: PHP_TEST_TIMEOUT_MS or 600000) + --host-reset-interval Kernel reboot interval per worker (default: PHP_TEST_HOST_RESET_INTERVAL or 25) + --run-uid Run guest PHP processes as uid n (default: PHP_TEST_RUN_UID) + --run-gid Run guest PHP processes as gid n (default: PHP_TEST_RUN_GID) + --start-offset Start at global sorted PHPT offset (default: 0) + --out-dir Output directory (default: /tmp/kandelo-php--chunks-) + --force Re-run chunks even if their .done marker exists + --summary-only Aggregate an existing --out-dir without running chunks + --rebuild-vfs Rebuild the browser PHPT VFS image before running + -h, --help Show this help + +Environment: + PHP_SOURCE_DIR php-src checkout (default: package metadata/cache resolver) + PHP_TEST_HOST Host default for --host (node or browser) + PHP_WASM PHP wasm binary (default resolved by downstream harness) + PHP_OPCACHE_SO opcache.so path when testing opcache (recommended) + PHP_EXTENSION_DIR Directory of PHP .so side modules to include in the browser VFS + PHP_TEST_RUN_UID Optional guest uid for PHP processes + PHP_TEST_RUN_GID Optional guest gid for PHP processes + +Outputs: + chunk-.jsonl JSONL PHPT results for that chunk + chunk-.stderr Harness stderr for that chunk + chunk-.exit Harness exit status for that chunk + chunk-.done Marker written only after the chunk command succeeds + summary.json Aggregated status counts and untested count + summary.md Human-readable summary +USAGE +} + +while [ "$#" -gt 0 ]; do + case "$1" in + --host) [ "$#" -ge 2 ] || die "--host needs a value"; host="$2"; shift 2 ;; + --chunk-size) [ "$#" -ge 2 ] || die "--chunk-size needs a value"; chunk_size="$2"; shift 2 ;; + --jobs) [ "$#" -ge 2 ] || die "--jobs needs a value"; jobs="$2"; shift 2 ;; + --timeout) [ "$#" -ge 2 ] || die "--timeout needs a value"; timeout_ms="$2"; shift 2 ;; + --host-reset-interval) [ "$#" -ge 2 ] || die "--host-reset-interval needs a value"; host_reset_interval="$2"; shift 2 ;; + --run-uid) [ "$#" -ge 2 ] || die "--run-uid needs a value"; run_uid="$2"; shift 2 ;; + --run-gid) [ "$#" -ge 2 ] || die "--run-gid needs a value"; run_gid="$2"; shift 2 ;; + --start-offset) [ "$#" -ge 2 ] || die "--start-offset needs a value"; start_offset="$2"; shift 2 ;; + --out-dir) [ "$#" -ge 2 ] || die "--out-dir needs a value"; out_dir="$2"; shift 2 ;; + --force) force=1; shift ;; + --summary-only) summary_only=1; shift ;; + --rebuild-vfs) rebuild_vfs=1; shift ;; + -h|--help) usage; exit 0 ;; + *) die "unknown option: $1" ;; + esac +done + +if [ -z "$jobs" ]; then + case "$host" in + browser) jobs=1 ;; + *) jobs=4 ;; + esac +fi + +for numeric in chunk_size jobs timeout_ms host_reset_interval start_offset; do + value="${!numeric}" + case "$value" in + ''|*[!0-9]*) die "$numeric must be a non-negative integer, got: $value" ;; + esac +done +for optional_numeric in run_uid run_gid; do + value="${!optional_numeric}" + case "$value" in + ''|*[!0-9]*) [ -z "$value" ] || die "$optional_numeric must be a non-negative integer, got: $value" ;; + esac +done +[ "$chunk_size" -gt 0 ] || die "chunk_size must be > 0" +[ "$jobs" -gt 0 ] || die "jobs must be > 0" +case "$host" in + node|browser) ;; + *) die "--host must be node or browser, got: $host" ;; +esac +if [ -z "$PHP_SRC" ]; then + PHP_SRC="$( + cd "$REPO_ROOT" + npx tsx scripts/run-php-upstream-tests.ts --print-source-dir | tail -n 1 + )" +fi +[ -d "$PHP_SRC" ] || die "PHP_SOURCE_DIR not found: $PHP_SRC" +export PHP_SOURCE_DIR="$PHP_SRC" + +if [ -z "$out_dir" ]; then + out_dir="/tmp/kandelo-php-$host-chunks-$(date -u +%Y%m%d%H%M%S)" +fi +mkdir -p "$out_dir" + +metadata="$out_dir/metadata.env" +{ + echo "REPO_ROOT=$REPO_ROOT" + echo "PHP_SOURCE_DIR=$PHP_SRC" + echo "HOST=$host" + echo "CHUNK_SIZE=$chunk_size" + echo "JOBS=$jobs" + echo "TIMEOUT_MS=$timeout_ms" + echo "HOST_RESET_INTERVAL=$host_reset_interval" + echo "RUN_UID=$run_uid" + echo "RUN_GID=$run_gid" + echo "START_OFFSET=$start_offset" + echo "STARTED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} > "$metadata" + +total=$(find "$PHP_SRC" -path '*/.git' -prune -o -path '*/.deps' -prune -o -path '*/.libs' -prune -o -name '*.phpt' -type f -print | wc -l | tr -d ' ') +echo "$total" > "$out_dir/total-tests.txt" + +echo "PHP source: $PHP_SRC" +echo "Host: $host" +echo "Total discovered PHPTs: $total" +echo "Output directory: $out_dir" + +if [ "$summary_only" -eq 0 ] && [ "$host" = browser ] && [ "$rebuild_vfs" -eq 1 ]; then + echo "Rebuilding browser PHPT VFS image..." + npx tsx "$REPO_ROOT/images/vfs/scripts/build-php-test-vfs-image.ts" +fi + +aggregate() { + python3 - "$out_dir" "$total" <<'PY' +import json +import sys +from collections import Counter +from pathlib import Path + +out_dir = Path(sys.argv[1]) +total = int(sys.argv[2]) +counts = Counter({ + "pass": 0, + "fail": 0, + "bork": 0, + "warn": 0, + "skip": 0, + "xfail": 0, + "xpass": 0, + "unsupported": 0, + "time": 0, +}) +results = {} +parse_errors = [] +for path in sorted(out_dir.glob("chunk-*.jsonl")): + with path.open("r", encoding="utf-8", errors="replace") as f: + for lineno, line in enumerate(f, 1): + line = line.strip() + if not line: + continue + try: + item = json.loads(line) + except json.JSONDecodeError as exc: + parse_errors.append(f"{path.name}:{lineno}: {exc}") + continue + test = item.get("test") + status = item.get("status") + if not test or not status: + parse_errors.append(f"{path.name}:{lineno}: missing test/status") + continue + previous = results.get(test) + if previous: + counts[previous.get("status", "")] -= 1 + results[test] = item + counts[status] += 1 + +run_total = len(results) +summary = { + "total_discovered": total, + "run_total": run_total, + "untested": max(total - run_total, 0), + "counts": dict(counts), + "parse_errors": parse_errors, +} +(out_dir / "summary.json").write_text(json.dumps(summary, indent=2, sort_keys=True) + "\n") +nonpassing = [r for r in results.values() if r.get("status") not in {"pass", "xfail"}] +nonpassing.sort(key=lambda r: (r.get("status", ""), r.get("test", ""))) +lines = [ + "# PHP PHPT Chunked Run Summary", + "", + f"Output directory: `{out_dir}`", + f"Total discovered: {total}", + f"Run total: {run_total}", + f"Untested: {summary['untested']}", + "", + "| Status | Count |", + "|---|---:|", +] +for key in ["pass", "fail", "bork", "warn", "skip", "xfail", "xpass", "unsupported", "time"]: + lines.append(f"| {key} | {counts[key]} |") +lines.extend(["", "## Non-passing/non-xfail results", ""]) +for r in nonpassing: + detail = r.get("reason") or r.get("detail") or "" + lines.append(f"- {r.get('status')} `{r.get('test')}`" + (f": {detail}" if detail else "")) +if parse_errors: + lines.extend(["", "## Parse errors", ""]) + lines.extend(f"- {e}" for e in parse_errors) +(out_dir / "summary.md").write_text("\n".join(lines) + "\n") +print(json.dumps(summary, sort_keys=True)) +PY +} + +if [ "$summary_only" -eq 1 ]; then + aggregate + exit 0 +fi + +offset="$start_offset" +while [ "$offset" -lt "$total" ]; do + tag=$(printf "%05d" "$offset") + jsonl="$out_dir/chunk-$tag.jsonl" + stderr="$out_dir/chunk-$tag.stderr" + exit_file="$out_dir/chunk-$tag.exit" + done_file="$out_dir/chunk-$tag.done" + if [ "$force" -eq 0 ] && [ -f "$done_file" ]; then + echo "[$(date -u +%H:%M:%S)] chunk offset $offset already done; skipping" + offset=$((offset + chunk_size)) + continue + fi + rm -f "$jsonl" "$stderr" "$exit_file" "$done_file" + echo "[$(date -u +%H:%M:%S)] running host=$host chunk offset=$offset limit=$chunk_size jobs=$jobs timeout=$timeout_ms reset=$host_reset_interval" + extra_args=() + if [ -n "$run_uid" ]; then + extra_args+=(--run-uid "$run_uid") + fi + if [ -n "$run_gid" ]; then + extra_args+=(--run-gid "$run_gid") + fi + set +e + "$REPO_ROOT/scripts/run-php-upstream-tests.sh" \ + --host "$host" \ + --all \ + --offset "$offset" \ + --limit "$chunk_size" \ + --jobs "$jobs" \ + --timeout "$timeout_ms" \ + --host-reset-interval "$host_reset_interval" \ + --allow-unsupported \ + ${extra_args[@]+"${extra_args[@]}"} \ + --json \ + > "$jsonl" 2> "$stderr" + status=$? + set -e + echo "$status" > "$exit_file" + if [ "$status" -eq 0 ]; then + date -u +%Y-%m-%dT%H:%M:%SZ > "$done_file" + aggregate || true + offset=$((offset + chunk_size)) + else + echo "chunk offset $offset failed with status $status; see $stderr" >&2 + aggregate || true + exit "$status" + fi +done + +{ + echo "FINISHED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ)" +} >> "$metadata" +aggregate diff --git a/scripts/run-php-upstream-tests.sh b/scripts/run-php-upstream-tests.sh new file mode 100755 index 0000000000..37a7c9a400 --- /dev/null +++ b/scripts/run-php-upstream-tests.sh @@ -0,0 +1,7 @@ +#!/usr/bin/env bash +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$REPO_ROOT" + +exec npx tsx scripts/run-php-upstream-tests.ts "$@" diff --git a/scripts/run-php-upstream-tests.ts b/scripts/run-php-upstream-tests.ts new file mode 100644 index 0000000000..6b352d7d80 --- /dev/null +++ b/scripts/run-php-upstream-tests.ts @@ -0,0 +1,2514 @@ +/** + * Run php-src PHPT runtime tests on Kandelo, through either the Node.js host + * or the browser host. + * + * This is intentionally a small PHPT harness instead of a native `make test` + * wrapper: upstream run-tests.php assumes it can spawn a native PHP binary. + * Here each --SKIPIF-- / --FILE-- / --CLEAN-- section is executed as a PHP + * process inside Kandelo and the harness performs the expectation match. + */ +import { chromium, type Browser, type Page } from "playwright"; +import { spawn, type ChildProcess, execFileSync } from "node:child_process"; +import { runInNewContext } from "node:vm"; +import { setFlagsFromString } from "node:v8"; +import { + existsSync, + chmodSync, + copyFileSync, + cpSync, + lstatSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + realpathSync, + rmSync, + statSync, + writeFileSync, +} from "node:fs"; +import { tmpdir } from "node:os"; +import { fileURLToPath } from "node:url"; +import { + basename, + delimiter, + dirname, + isAbsolute, + join, + relative, + resolve, + sep, +} from "node:path"; +import { NodeKernelHost } from "../host/src/node-kernel-host"; +import { ABI_SYSCALL_NAMES } from "../host/src/generated/abi"; +import { ensureSourceExtract } from "../images/vfs/scripts/source-extract-helper"; +import { preparePhpTestFixtures } from "../images/vfs/scripts/php-test-fixtures"; +import { resolvePackageRuntimeFile } from "./package-runtime-file"; + +const REPO_ROOT = resolve(new URL(".", import.meta.url).pathname, ".."); +const LOCAL_PHP_SRC = join(REPO_ROOT, "packages/registry/php/php-src"); +const BROWSER_PUBLIC_DIR = join(REPO_ROOT, "apps/browser-demos/public"); +const PHP_TEST_VFS = resolve( + process.env.PHP_TEST_VFS_OUT ?? + join(REPO_ROOT, "apps/browser-demos/public/php-test.vfs.zst"), +); +const BROWSER_DIR = join(REPO_ROOT, "apps/browser-demos"); +const VITE_HOST = "127.0.0.1"; +const VITE_PORT = Number(process.env.PHP_TEST_VITE_PORT ?? 5201); +const BROWSER_EXTENSION_DIR = "/usr/lib/php/extensions"; +const PHP_ICU_RUNTIME = resolvePackageRuntimeFile(REPO_ROOT, "php", "icu.dat"); +const RUN_TESTS_BASE_INI = [ + "output_handler=", + "open_basedir=", + "disable_functions=", + "output_buffering=Off", + "error_reporting=32767", + "display_errors=1", + "display_startup_errors=1", + "log_errors=0", + "html_errors=0", + "track_errors=0", + "report_memleaks=1", + "report_zend_debug=0", + "docref_root=", + "docref_ext=.html", + "error_prepend_string=", + "error_append_string=", + "auto_prepend_file=", + "auto_append_file=", + "ignore_repeated_errors=0", + "precision=14", + "serialize_precision=-1", + "memory_limit=128M", + "opcache.fast_shutdown=0", + "opcache.file_update_protection=0", + "opcache.revalidate_freq=0", + "opcache.jit_hot_loop=1", + "opcache.jit_hot_func=1", + "opcache.jit_hot_return=1", + "opcache.jit_hot_side_exit=1", + "zend.assertions=1", + "zend.exception_ignore_args=0", + "zend.exception_string_param_max_len=15", + "short_open_tag=0", +]; + +const FAILURE_SNIPPET_BYTES = Math.max( + 2000, + parseInt(process.env.PHP_TEST_FAILURE_SNIPPET_BYTES ?? "2000", 10) || 2000, +); +const BROWSER_WASM_STACK_JS_FLAGS = [ + // Chromium dedicated Web Workers expose only the default V8 native stack, + // which is too small for legitimate stack-heavy Wasm workloads. Keep + // browser-host PHPT runs on V8's secondary Wasm stack and raise that stack + // so deep guest recursion behaves like the Node host's larger worker stack. + "--stack-size=32768", + "--stress-wasm-stack-switching", + "--wasm-stack-switching-stack-size=32768", + "--experimental-wasm-growable-stacks", +].join(" "); + +type HostKind = "node" | "browser"; +export type TestStatus = + | "pass" + | "fail" + | "bork" + | "warn" + | "skip" + | "xfail" + | "xpass" + | "unsupported" + | "time"; + +export interface PhptTest { + path: string; + rel: string; + sourceRoot: string; + sections: Record; +} + +export interface PhpRunResult { + exitCode: number; + stdout: string; + stderr: string; + output?: string; + error?: string; + durationMs: number; +} + +export interface TestResult { + test: string; + status: TestStatus; + time_ms: number; + reason?: string; + detail?: string; +} + +export interface PhpRunner { + loadExtensionIniArgs(requiredExtensions: string[]): string[]; + runScript(opts: { + test: PhptTest; + kind: "skipif" | "file" | "clean"; + script: string; + argv: string[]; + scriptArgs?: string[]; + env: string[]; + stdin?: string; + waitForChildOutput?: boolean; + timeoutMs: number; + }): Promise; + endTest?(): Promise; + close(): Promise; +} + +let tempCounter = 0; + +const PASSTHROUGH_ENV_NAMES = [ + "NO_INTERACTION", + "RES_OPTIONS", + "SKIP_IO_CAPTURE_TESTS", + "SKIP_ONLINE_TESTS", + "SKIP_PERF_SENSITIVE", + "SKIP_SLOW_TESTS", + "TEST_FPM_DEBUG", + "TEST_FPM_RUN_AS_ROOT", + "FPM_RUN_RESOURCE_HEAVY_TESTS", + "TEST_NON_ROOT_USER", +]; + +function forceNodeGc(): void { + try { + setFlagsFromString("--expose-gc"); + const gc = runInNewContext("gc") as () => void; + gc(); + } catch { + // Best-effort: Node may disable exposing gc in some embeddings. + } +} + +function loadBytes(path: string): ArrayBuffer { + const buf = readFileSync(path); + return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength); +} + +function delay(ms: number): Promise { + return new Promise((resolveDelay) => setTimeout(resolveDelay, ms)); +} + +async function withTimeout( + promise: Promise, + ms: number, + label: string, +): Promise { + let timeoutId: ReturnType | undefined; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error(`${label} timed out after ${ms}ms`)), + ms, + ); + }), + ]); + } finally { + if (timeoutId !== undefined) clearTimeout(timeoutId); + } +} + +function resolvePhpBinary(): string { + const candidate = + process.env.PHP_WASM ?? + PHP_ICU_RUNTIME?.closureHostPaths.get("php/php.wasm") ?? + join(LOCAL_PHP_SRC, "sapi/cli/php"); + if (!candidate || !existsSync(candidate)) { + throw new Error( + "PHP wasm not found. Run: bash packages/registry/php/build-php.sh", + ); + } + return candidate; +} + +function resolvePhpFpmBinary(phpPath: string): string | null { + const explicit = process.env.PHP_FPM_WASM; + if (explicit) return resolve(explicit); + const resolved = PHP_ICU_RUNTIME?.closureHostPaths.get("php/php-fpm.wasm"); + if (resolved) return resolved; + const sibling = join(dirname(phpPath), "php-fpm.wasm"); + return existsSync(sibling) ? sibling : null; +} + +export function resolvePhpSource(): string { + const explicit = process.env.PHP_SOURCE_DIR; + if (explicit) return resolve(explicit); + return ensureSourceExtract( + "php", + REPO_ROOT, + existsSync(LOCAL_PHP_SRC) ? LOCAL_PHP_SRC : undefined, + ); +} + +function parsePhpt(path: string, sourceRoot: string): PhptTest { + // PHPT files are byte-oriented. A few upstream tests intentionally contain + // non-UTF-8 PHP source/EXPECT bytes, so keep a one-code-point-per-byte + // representation and write/capture generated scripts the same way. + const text = readFileSync(path, "latin1"); + const marker = /^--([A-Z_]+)--[ \t]*\r?$/gm; + const matches = [...text.matchAll(marker)]; + const sections: Record = {}; + for (let i = 0; i < matches.length; i++) { + const name = matches[i][1]; + const start = (matches[i].index ?? 0) + matches[i][0].length; + const end = i + 1 < matches.length ? matches[i + 1].index! : text.length; + sections[name] = text.slice(start, end).replace(/^\r?\n/, ""); + } + return { path, rel: relative(sourceRoot, path), sourceRoot, sections }; +} + +function walkPhpt(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if ( + entry.name === ".git" || + entry.name === ".deps" || + entry.name === ".libs" + ) + continue; + const full = join(dir, entry.name); + if (entry.isDirectory()) { + walkPhpt(full, out); + } else if (entry.isFile() && entry.name.endsWith(".phpt")) { + out.push(full); + } + } + return out; +} + +function discoverTests(sourceRoot: string, selectors: string[]): PhptTest[] { + const realSourceRoot = realpathSync(sourceRoot); + const files: string[] = []; + if (selectors.length === 0) { + walkPhpt(sourceRoot, files); + } else { + for (const selector of selectors) { + const resolved = isAbsolute(selector) + ? selector + : resolve(sourceRoot, selector); + if (!existsSync(resolved)) + throw new Error(`PHPT selector not found: ${selector}`); + const realResolved = realpathSync(resolved); + const rel = relative(realSourceRoot, realResolved); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error(`PHPT selector escapes php-src: ${selector}`); + } + const st = statSync(realResolved); + if (st.isDirectory()) walkPhpt(realResolved, files); + else if (realResolved.endsWith(".phpt")) files.push(realResolved); + else throw new Error(`PHPT selector is not a .phpt file: ${selector}`); + } + } + return [...new Set(files)].sort().map((path) => parsePhpt(path, sourceRoot)); +} + +export function splitArgs(input: string | undefined): string[] { + if (!input) return []; + const out: string[] = []; + let current = ""; + let tokenStarted = false; + let quote: "'" | '"' | null = null; + let escape = false; + for (const ch of input.trim()) { + if (escape) { + current += ch; + tokenStarted = true; + escape = false; + } else if (ch === "\\") { + tokenStarted = true; + escape = true; + } else if (quote) { + if (ch === quote) quote = null; + else current += ch; + } else if (ch === "'" || ch === '"') { + tokenStarted = true; + quote = ch; + } else if (/\s/.test(ch)) { + if (tokenStarted) { + out.push(current); + current = ""; + tokenStarted = false; + } + } else { + current += ch; + tokenStarted = true; + } + } + if (escape) current += "\\"; + if (tokenStarted) out.push(current); + return out; +} + +function extraChromiumArgsFromEnv(): string[] { + const args = [ + ...splitArgs(process.env.PHP_TEST_CHROMIUM_ARGS), + ...splitArgs(process.env.KANDELO_CHROMIUM_ARGS), + ]; + if (process.env.PHP_TEST_DISABLE_BROWSER_WASM_STACK_FLAGS !== "1") { + args.unshift(`--js-flags=${BROWSER_WASM_STACK_JS_FLAGS}`); + } + return args; +} + +function guestTestDir(test: PhptTest): string { + const relDir = dirname(test.rel).split("\\").join("/"); + return relDir === "." ? "/php-src" : `/php-src/${relDir}`; +} + +function expandSectionPlaceholders(value: string, test: PhptTest): string { + return value + .replaceAll("{PWD}", guestTestDir(test)) + .replaceAll("{TMP}", "/tmp") + .replace(/\{MAIL:([^}]+)\}/g, (_match, path) => `tee ${path} >/dev/null`) + .replace(/\{ENV:([^}]+)\}/g, (_match, name) => { + if (name !== "TEST_NON_ROOT_USER") { + throw new Error( + `${test.rel}: unsupported host environment placeholder {ENV:${name}}`, + ); + } + return process.env[name] ?? ""; + }); +} + +function iniArgs(ini: string | undefined, test: PhptTest): string[] { + if (!ini) return []; + const args: string[] = []; + for (const raw of expandSectionPlaceholders(ini, test).split(/\r?\n/)) { + let line = raw.trim(); + if (!line || line.startsWith(";") || line.startsWith("#")) continue; + const eq = line.indexOf("="); + if (eq < 0) continue; + if (eq >= 0) { + const key = line.slice(0, eq).trim(); + const value = line.slice(eq + 1).trim(); + line = `${key}=${value}`; + } + args.push("-d", line); + } + return args; +} + +function envArgs(env: string | undefined, test: PhptTest): string[] { + if (!env) return []; + const args: string[] = []; + for (const raw of expandSectionPlaceholders(env, test).split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#")) continue; + const eq = line.indexOf("="); + // Upstream run-tests.php feeds --ENV-- through PHP's proc_open() + // environment array. proc_open's POSIX envp builder intentionally skips + // entries whose value is an empty string, so mirror that rather than + // passing NAME= directly to Kandelo. + if (eq >= 0 && line.slice(eq + 1).length === 0) continue; + args.push(line); + } + return args; +} + +function passthroughEnvArgs(): string[] { + return PASSTHROUGH_ENV_NAMES.flatMap((name) => + process.env[name] === undefined ? [] : [`${name}=${process.env[name]}`], + ); +} + +function defaultPhpTestEnvArgs(): string[] { + // Stable harness inputs shared by all sections. php-src's CGI-like request + // variables are section-specific and are added by envForSection(). + return [ + // Kandelo runs FPM and its helper clients under emulation, so PHP-FPM + // startup notices can legitimately take longer than php-src's native + // three-second tester default (especially with OPcache preloading). + // The fixture patch below teaches the FPM tester helper to honor this. + `TEST_FPM_LOG_TIMEOUT_SECONDS=${process.env.TEST_FPM_LOG_TIMEOUT_SECONDS ?? "20"}`, + `TEST_FPM_CHECK_CONNECTION_ATTEMPTS=${process.env.TEST_FPM_CHECK_CONNECTION_ATTEMPTS ?? "200"}`, + `TEST_FPM_READ_WRITE_TIMEOUT_MS=${process.env.TEST_FPM_READ_WRITE_TIMEOUT_MS ?? "20000"}`, + "TEST_FPM_EXTENSION_DIR=/usr/lib/php/extensions", + `TEST_NON_ROOT_USER=${process.env.TEST_NON_ROOT_USER ?? "nobody"}`, + ]; +} + +function parseOptionalNonNegativeInt(value: string | undefined, name: string): number | undefined { + if (value === undefined || value === "") return undefined; + const parsed = Number(value); + if (!Number.isInteger(parsed) || parsed < 0) { + throw new Error(`${name} must be a non-negative integer, got ${value}`); + } + return parsed; +} + +function mergeEnvArgs(...groups: string[][]): string[] { + const merged = new Map(); + for (const group of groups) { + for (const entry of group) { + const eq = entry.indexOf("="); + if (eq <= 0) continue; + merged.set(entry.slice(0, eq), entry); + } + } + return [...merged.values()]; +} + +function phptConflictTokens(test: PhptTest): string[] { + const tokens = new Set(); + const conflicts = test.sections.CONFLICTS ?? ""; + for (const token of conflicts.split(/[\s,]+/)) { + const normalized = token.trim(); + if (normalized) tokens.add(normalized); + } + + const source = [ + test.sections.SKIPIF, + test.sections.FILE, + test.sections.FILEEOF, + test.sections.CLEAN, + ] + .filter((section): section is string => section !== undefined) + .join("\n"); + + // Upstream run-tests.php uses --CONFLICTS-- to keep server-style PHPTs from + // running concurrently. Some php-src tests do not declare it even though + // they start helper servers or bind fixed loopback ports. Mirror the + // important resource constraints here so `--jobs` remains usable without + // producing false failures from EADDRINUSE or competing php_cli_server + // instances. + if ( + /\b(?:php_cli_server_start|php_cli_server_connect|PHP_CLI_SERVER_)/.test( + source, + ) || + /\bServerClientTestCase\.inc\b/.test(source) + ) { + tokens.add("server"); + } + + const loopbackPort = + /\b(?:127\.0\.0\.1|localhost|\[::1\]|::1):([0-9]{2,5})\b/g; + for (const match of source.matchAll(loopbackPort)) { + tokens.add(`tcp-port:${match[1]}`); + } + + return [...tokens]; +} + +function requiresExclusiveScheduling(conflicts: string[]): boolean { + // Server-style PHPTs commonly start a helper PHP process, sleep briefly, and + // then connect to a fixed loopback listener. The declared `server` conflict + // prevents port/helper overlap, but under Kandelo's Wasm host even unrelated + // concurrent PHPTs can consume enough CPU during PHP startup to turn those + // upstream timing assumptions into false connection-refused failures. Run + // server tests exclusively rather than skipping or patching them. + return conflicts.includes("server"); +} + +function shellEscape(value: string): string { + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function shellArgs(args: string[]): string { + return args.map(shellEscape).join(" "); +} + +function baseIniArgs(): string[] { + return RUN_TESTS_BASE_INI.flatMap((setting) => ["-d", setting]); +} + +function extensionArgs(extensions: string | undefined): string[] { + if (!extensions) return []; + return extensions + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith("#")); +} + +function normalizeExtensionName(extension: string): string { + const name = extension.trim().toLowerCase(); + if (name === "zend opcache") return "opcache"; + return name.replace(/^(?:php_)?(.+?)(?:\.so)?$/, "$1"); +} + +function sharedExtensionPathsForPhp(phpPath: string): Map { + const out = new Map(); + const extensionDirs = [ + dirname(phpPath), + ...((process.env.PHP_EXTENSION_DIR ?? "") + .split(delimiter) + .map((dir) => dir.trim()) + .filter(Boolean)), + ]; + for (const dir of extensionDirs) { + if (!existsSync(dir)) continue; + for (const entry of readdirSync(dir)) { + if (entry.endsWith(".so")) { + out.set(normalizeExtensionName(entry), join(dir, entry)); + } + } + } + // When the declared package closure is materialized, use its selected + // side-module paths directly. They were resolved together with php.wasm and + // icu.dat from one complete provenance tier. + for (const name of [ + "opcache", + "curl", + "phar", + "zend_test", + "zip", + "intl", + ]) { + const resolved = PHP_ICU_RUNTIME?.closureHostPaths.get(`php/${name}.so`); + if (resolved) out.set(name, resolved); + } + const phpDir = dirname(phpPath); + const opcachePath = + process.env.PHP_OPCACHE_SO ?? + PHP_ICU_RUNTIME?.closureHostPaths.get("php/opcache.so") ?? + join(phpDir, "opcache.so"); + if (opcachePath && existsSync(opcachePath)) out.set("opcache", opcachePath); + return out; +} + +function loadExtensionIniArgs( + requiredExtensions: string[], + availableSharedExtensions: Set, + guestExtensionDir: string, +): string[] { + const args: string[] = []; + let emittedExtensionDir = false; + for (const extension of requiredExtensions) { + const name = normalizeExtensionName(extension); + if (!availableSharedExtensions.has(name)) continue; + if (!emittedExtensionDir) { + args.push("-d", `extension_dir=${guestExtensionDir}`); + emittedExtensionDir = true; + } + const directive = + name === "opcache" || name === "xdebug" ? "zend_extension" : "extension"; + args.push("-d", `${directive}=${guestExtensionDir}/${name}.so`); + if (name === "opcache") { + // Kandelo has no cross-process MAP_SHARED yet, so the packaged extension + // rejects the normal SHM mode. Exercise its supported file-cache-only + // boundary explicitly; individual PHPT --INI-- sections can still expose + // unsupported SHM assumptions as real failures. + args.push( + "-d", + "opcache.enable=1", + "-d", + "opcache.enable_cli=1", + "-d", + "opcache.file_cache=/tmp", + "-d", + "opcache.file_cache_only=1", + ); + } + } + return args; +} + +function normalizeOutput(text: string): string { + // Upstream php-src run-tests.php normalizes CRLF and compares PHP + // trim($out) against trim(EXPECT*). PHP trim's default charlist includes + // NUL bytes, unlike JavaScript String#trim(). + return text + .replace(/\r\n/g, "\n") + .replace(/^[\x00\t\n\v\r ]+|[\x00\t\n\v\r ]+$/g, ""); +} + +function escapeRegExp(text: string): string { + return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function replaceExpectfPlaceholders(text: string): string { + return text.replace(/%[easSAwidxfc0]/g, (token) => { + switch (token) { + case "%e": + return "/"; + case "%s": + return "[^\\r\\n]+"; + case "%S": + return "[^\\r\\n]*"; + case "%a": + return ".+"; + case "%A": + return "[\\s\\S]*"; + case "%w": + return "\\s*"; + case "%i": + return "[+-]?\\d+"; + case "%d": + return "\\d+"; + case "%x": + return "[0-9a-fA-F]+"; + case "%f": + return "[+-]?(?:\\d+|(?=\\.\\d))(?:\\.\\d+)?(?:[Ee][+-]?\\d+)?"; + case "%c": + return "."; + case "%0": + return "\\x00"; + default: + return escapeRegExp(token); + } + }); +} + +function pcrePatternToJs(pattern: string): string { + // PHP's PCRE \R token has no JavaScript equivalent. Preserve its generic + // newline semantics rather than letting JS interpret it as a literal "R". + return pattern.replace( + /\\R/g, + "(?:\\r\\n|[\\n\\r\\v\\f\\x85\\u2028\\u2029])", + ); +} + +function expectfToRegExp(expectf: string): RegExp { + let out = ""; + for (let i = 0; i < expectf.length; i++) { + if (expectf.startsWith("%r", i)) { + const end = expectf.indexOf("%r", i + 2); + if (end !== -1) { + out += `(${pcrePatternToJs(expectf.slice(i + 2, end))})`; + i = end + 1; + continue; + } + } + out += escapeRegExp(expectf[i]); + } + // Upstream run-tests.php first preg_quote()s non-%r sections, leaves %r + // regex spans raw, then applies EXPECTF %-placeholder substitutions to the + // whole pattern. Do not treat %% specially: literal percent signs remain + // literal unless followed by a recognized placeholder character. + return new RegExp(`^${replaceExpectfPlaceholders(out)}$`, "s"); +} + +export function compareExpectation( + test: PhptTest, + actualRaw: string, +): { ok: boolean; detail?: string } { + const actual = normalizeOutput(actualRaw); + if (test.sections.EXPECT !== undefined) { + const expected = normalizeOutput(test.sections.EXPECT); + return { + ok: actual === expected, + detail: + actual === expected + ? undefined + : `expected exact output length ${expected.length}, got ${actual.length}`, + }; + } + if (test.sections.EXPECTF !== undefined) { + const expected = normalizeOutput(test.sections.EXPECTF); + const re = expectfToRegExp(expected); + const matched = re.test(actual); + return { + ok: matched, + detail: matched ? undefined : "EXPECTF pattern did not match", + }; + } + if (test.sections.EXPECTREGEX !== undefined) { + const expected = normalizeOutput(test.sections.EXPECTREGEX); + // php-src's run-tests.php wraps EXPECTREGEX in ^...$ before matching. + // Leaving it unanchored would let otherwise unexpected prefix/suffix + // output turn a failing PHPT into a false pass. + const re = new RegExp(`^${pcrePatternToJs(expected)}$`, "s"); + const matched = re.test(actual); + return { + ok: matched, + detail: matched ? undefined : "EXPECTREGEX pattern did not match", + }; + } + return { ok: false, detail: "no supported EXPECT section" }; +} + +function failureSnippet(actualOutput: string): string { + return normalizeOutput(actualOutput) + .slice(0, FAILURE_SNIPPET_BYTES) + .replace(/\n/g, "\\n"); +} + +function fileExternalPath(test: PhptTest): string | null { + const raw = test.sections.FILE_EXTERNAL?.trim(); + if (!raw) return null; + if (isAbsolute(raw)) { + throw new Error(`${test.rel}: FILE_EXTERNAL must be relative to the PHPT`); + } + const candidate = resolve(dirname(test.path), raw); + const lexicalRel = relative(test.sourceRoot, candidate); + if ( + lexicalRel === ".." || + lexicalRel.startsWith(`..${sep}`) || + isAbsolute(lexicalRel) + ) { + throw new Error(`${test.rel}: FILE_EXTERNAL escapes php-src: ${raw}`); + } + if (!existsSync(candidate)) return candidate; + const realCandidate = realpathSync(candidate); + const realRoot = realpathSync(test.sourceRoot); + const realRel = relative(realRoot, realCandidate); + if (realRel === ".." || realRel.startsWith(`..${sep}`) || isAbsolute(realRel)) { + throw new Error( + `${test.rel}: FILE_EXTERNAL resolves outside php-src: ${raw}`, + ); + } + return realCandidate; +} + +function unsupportedReason(test: PhptTest): string | null { + if (test.sections.CAPTURE_STDIO !== undefined) { + const capture = test.sections.CAPTURE_STDIO.toUpperCase(); + if ( + !capture.includes("STDIN") || + !capture.includes("STDOUT") || + !capture.includes("STDERR") + ) { + return "partial CAPTURE_STDIO requires per-descriptor inheritance in the host spawn contract"; + } + } + const requiredExtensions = extensionArgs(test.sections.EXTENSIONS).map( + normalizeExtensionName, + ); + const source = `${test.sections.SKIPIF ?? ""}\n${test.sections.FILE ?? ""}\n${test.sections.FILEEOF ?? ""}`; + const explicitlyLoadsOpcache = + /(?:^|[^\w])(?:-d)?zend_extension\s*=\s*["']?opcache(?:\.so)?\b/im.test( + source, + ); + const ini = test.sections.INI ?? ""; + const opcacheConfiguration = `${ini}\n${source}`; + const explicitlyActivatesOpcacheCli = + /(?:^|[^\w])(?:-d)?opcache\.enable_cli\s*=\s*(?:1|on|yes|true)\b/im.test( + opcacheConfiguration, + ) || + /(?:^|[^\w])(?:-d)?opcache\.preload\s*=\s*[^\s'";]+/im.test( + opcacheConfiguration, + ); + if ( + requiredExtensions.includes("opcache") || + (explicitlyLoadsOpcache && explicitlyActivatesOpcacheCli) + ) { + const fileCacheOnly = + /(?:^|[^\w])(?:-d)?opcache\.file_cache_only\s*=\s*(?:1|on|yes|true)\b/im.test( + opcacheConfiguration, + ); + const fileCachePath = + /(?:^|[^\w])(?:-d)?opcache\.file_cache(?!_only)\s*=\s*[^\s'";]+/im.test( + opcacheConfiguration, + ); + if (!fileCacheOnly || !fileCachePath) { + return "opcache SHM mode requires unsupported cross-process MAP_SHARED; only explicit file-cache-only PHPTs are supported"; + } + } + if (test.sections.REDIRECTTEST !== undefined) + return "REDIRECTTEST is not supported by the Kandelo PHPT harness yet"; + if (test.sections.PHPDBG !== undefined) { + return "PHPDBG requires the phpdbg SAPI and command-stream handling, which the Kandelo PHPT harness does not provide yet"; + } + if ( + /\b(?:dns_get_record|dns_get_mx|getmxrr|checkdnsrr|dns_check_record)\s*\(/.test( + source, + ) + ) { + return "PHP DNS record-query functions are not enabled in the Kandelo PHP build"; + } + if ( + test.rel.startsWith("Zend/tests/fibers/") || + /\b(?:new\s+\\?Fiber|\\?Fiber::|ReflectionFiber|_?ZendTestFiber)\b/.test(source) + ) { + return "PHP Fibers require ucontext/boost context switching, which the Kandelo PHP build does not support yet"; + } + const sapiOnly = [ + "POST", + "POST_RAW", + "PUT", + "GET", + "COOKIE", + "REQUEST", + "HEADERS", + "EXPECTHEADERS", + "GZIP_POST", + "DEFLATE_POST", + "CGI", + ].find((section) => test.sections[section] !== undefined); + if (sapiOnly) return `${sapiOnly} requires web/CGI PHPT handling`; + if ( + test.sections.FILE === undefined && + test.sections.FILEEOF === undefined && + test.sections.FILE_EXTERNAL === undefined + ) { + return "no FILE/FILEEOF/FILE_EXTERNAL section"; + } + if ( + test.sections.FILE_EXTERNAL !== undefined && + !existsSync(fileExternalPath(test) ?? "") + ) { + return `FILE_EXTERNAL target not found: ${test.sections.FILE_EXTERNAL.trim()}`; + } + if ( + test.sections.EXPECT === undefined && + test.sections.EXPECTF === undefined && + test.sections.EXPECTREGEX === undefined + ) { + return "no supported EXPECT section"; + } + return null; +} + +export function testScript(test: PhptTest): string { + if (test.sections.FILE !== undefined) return test.sections.FILE; + if (test.sections.FILEEOF !== undefined) { + return test.sections.FILEEOF.replace(/[\r\n]+$/, ""); + } + if (test.sections.FILE_EXTERNAL !== undefined) { + const externalPath = fileExternalPath(test); + if (!externalPath) return ""; + return readFileSync(externalPath, "latin1"); + } + return ""; +} + +function phptGeneratedScriptName(test: PhptTest, kind: string): string { + const base = basename(test.path, ".phpt"); + if (kind === "file") { + return `${base}.php`; + } + if (kind === "clean") { + return `${base}.clean.php`; + } + if (kind === "skipif") { + return `${base}.skip.php`; + } + return `.kandelo-phpt-${process.pid}-${tempCounter++}-${kind}.php`; +} + +function hostTestDir(test: PhptTest, sourceRoot: string): string { + const relDir = dirname(test.rel); + return relDir === "." ? sourceRoot : join(sourceRoot, relDir); +} + +function nodeTempPath(test: PhptTest, sourceRoot: string, scriptName: string): string { + return join(hostTestDir(test, sourceRoot), scriptName); +} + +function guestScriptPath( + test: PhptTest, + _sourceRoot: string, + scriptName: string, +): string { + const relDir = dirname(test.rel).split("\\").join("/"); + return relDir && relDir !== "." + ? `/php-src/${relDir}/${scriptName}` + : `/php-src/${scriptName}`; +} + +const UNSET_FOR_AUXILIARY_SECTIONS = new Set([ + "QUERY_STRING", + "PATH_TRANSLATED", + "SCRIPT_FILENAME", + "REQUEST_METHOD", +]); + +function withoutEnvNames(env: string[], names: Set): string[] { + return env.filter((entry) => { + const eq = entry.indexOf("="); + return eq < 0 || !names.has(entry.slice(0, eq)); + }); +} + +function hasEnvName(env: string[], name: string): boolean { + return env.some((entry) => entry.startsWith(`${name}=`)); +} + +function envForSection( + test: PhptTest, + kind: "skipif" | "file" | "clean", + env: string[], +): string[] { + // Match php-src 8.3's run_test() mutations. Empty values are absent because + // PHP's POSIX envp builder drops them. SKIPIF explicitly unsets only the + // request/path quartet after applying --ENV--. + if (kind === "skipif") { + return withoutEnvNames(env, UNSET_FOR_AUXILIARY_SECTIONS); + } + + // FILE clears content metadata, forces an ordinary CLI request method and + // redirect status, and preserves --ENV-- query/script paths when supplied. + if (kind === "file") { + const fileEnv = withoutEnvNames( + env, + new Set([ + "REDIRECT_STATUS", + "REQUEST_METHOD", + "CONTENT_TYPE", + "CONTENT_LENGTH", + "HTTP_COOKIE", + ]), + ); + const scriptName = phptGeneratedScriptName(test, kind); + const scriptPath = guestScriptPath(test, test.sourceRoot, scriptName); + return mergeEnvArgs(fileEnv, [ + "REDIRECT_STATUS=1", + ...(hasEnvName(fileEnv, "PATH_TRANSLATED") + ? [] + : [`PATH_TRANSLATED=${scriptPath}`]), + ...(hasEnvName(fileEnv, "SCRIPT_FILENAME") + ? [] + : [`SCRIPT_FILENAME=${scriptPath}`]), + "REQUEST_METHOD=GET", + ]); + } + + // CLEAN inherits FILE's redirect status and cleared content metadata, then + // explicitly unsets the same request/path quartet as SKIPIF. + return mergeEnvArgs( + withoutEnvNames( + env, + new Set([ + ...UNSET_FOR_AUXILIARY_SECTIONS, + "REDIRECT_STATUS", + "CONTENT_TYPE", + "CONTENT_LENGTH", + "HTTP_COOKIE", + ]), + ), + ["REDIRECT_STATUS=1"], + ); +} + +class NodePhpRunner implements PhpRunner { + private virtualPhpPath: string; + private host: NodeKernelHost | null = null; + private phpBytes: ArrayBuffer | null = null; + private binaryMountRoot: string | null = null; + private extensionMountRoot: string | null = null; + private testsSinceReset = 0; + private activeOutput: { stdout: string; stderr: string; output: string } | null = + null; + + constructor( + private sourceRoot: string, + private phpPath: string, + private phpFpmPath: string | null, + private sharedExtensionPaths: Map, + private ownsSourceRoot = false, + private hostResetInterval = 50, + private enableTcpNetwork = true, + private runUid?: number, + private runGid?: number, + ) { + this.virtualPhpPath = `/kandelo-bin/${basename(phpPath)}`; + } + + loadExtensionIniArgs(requiredExtensions: string[]): string[] { + return loadExtensionIniArgs( + requiredExtensions, + new Set(this.sharedExtensionPaths.keys()), + BROWSER_EXTENSION_DIR, + ); + } + + private ensureExtensionMountRoot(): string { + if (this.extensionMountRoot) return this.extensionMountRoot; + const root = mkdtempSync(join(tmpdir(), "kandelo-php-ext-")); + chmodSync(root, 0o755); + const destDir = join(root, "php", "extensions"); + mkdirSync(destDir, { recursive: true }); + chmodSync(join(root, "php"), 0o755); + chmodSync(destDir, 0o755); + for (const [name, srcPath] of this.sharedExtensionPaths) { + const destPath = join(destDir, `${name}.so`); + // Binary resolver outputs are commonly symlinks into the content cache. + // A copied symlink would point outside this host-backed mount and be + // rejected by HostFileSystem's sandbox, so materialize the file bytes. + copyFileSync(srcPath, destPath); + chmodSync(destPath, 0o755); + } + if (this.sharedExtensionPaths.has("intl")) { + if (!PHP_ICU_RUNTIME) { + throw new Error( + "intl.so is available but the declared php:icu.dat runtime file is not materialized", + ); + } + const mountPoint = "/usr/lib"; + if (!PHP_ICU_RUNTIME.guestPath.startsWith(`${mountPoint}/`)) { + throw new Error( + `php:icu.dat guest path ${PHP_ICU_RUNTIME.guestPath} is outside ${mountPoint}`, + ); + } + const runtimeDest = join( + root, + ...PHP_ICU_RUNTIME.guestPath.slice(mountPoint.length + 1).split("/"), + ); + mkdirSync(dirname(runtimeDest), { recursive: true }); + copyFileSync(PHP_ICU_RUNTIME.hostPath, runtimeDest); + chmodSync(runtimeDest, PHP_ICU_RUNTIME.mode); + } + this.extensionMountRoot = root; + return root; + } + + private ensureBinaryMountRoot(): string { + if (this.binaryMountRoot) return this.binaryMountRoot; + const root = mkdtempSync(join(tmpdir(), "kandelo-php-bin-")); + chmodSync(root, 0o755); + const phpDest = join(root, basename(this.phpPath)); + copyFileSync(this.phpPath, phpDest); + chmodSync(phpDest, 0o755); + if (this.phpFpmPath && existsSync(this.phpFpmPath)) { + const sbin = join(root, "sbin"); + mkdirSync(sbin, { recursive: true }); + chmodSync(sbin, 0o755); + // php-src's FPM PHPT helper searches for TEST_PHP_EXECUTABLE's + // prefix + /sbin/php-fpm (or /fpm/php-fpm). Provide that normal + // package layout in the guest rather than teaching individual tests + // about Kandelo's .wasm artifact name. + const fpmDest = join(sbin, "php-fpm"); + copyFileSync(this.phpFpmPath, fpmDest); + chmodSync(fpmDest, 0o755); + } + this.binaryMountRoot = root; + return root; + } + + private async ensureHost(): Promise { + if (this.host) return this.host; + this.phpBytes = loadBytes(this.phpPath); + const binaryMountRoot = this.ensureBinaryMountRoot(); + const extensionMountRoot = this.ensureExtensionMountRoot(); + const host = new NodeKernelHost({ + maxWorkers: 4, + rootfsImage: "default", + enableTcpNetwork: this.enableTcpNetwork, + execPrograms: { + [this.virtualPhpPath]: this.phpPath, + "/kandelo-bin/php": this.phpPath, + ...(this.phpFpmPath + ? { + "/kandelo-bin/sbin/php-fpm": this.phpFpmPath, + "/kandelo-bin/fpm/php-fpm": this.phpFpmPath, + } + : {}), + }, + extraMounts: [ + { + mountPoint: "/php-src", + hostPath: this.sourceRoot, + // The source tree is the PHPT workspace. Present it as owned by the + // guest account selected for this run so non-root FPM/CLI coverage + // exercises normal POSIX permission checks instead of a root-owned + // host mount. + uid: this.runUid, + gid: this.runGid, + }, + { + mountPoint: "/kandelo-bin", + hostPath: binaryMountRoot, + readonly: true, + }, + { + mountPoint: "/usr/lib", + hostPath: extensionMountRoot, + readonly: true, + }, + ], + onStdout: (_pid, data) => { + if (this.activeOutput) { + const text = Buffer.from(data).toString("latin1"); + this.activeOutput.stdout += text; + this.activeOutput.output += text; + } + }, + onStderr: (_pid, data) => { + if (this.activeOutput) { + const text = Buffer.from(data).toString("latin1"); + this.activeOutput.stderr += text; + this.activeOutput.output += text; + } + }, + }); + await host.init(); + if (process.env.PHP_TEST_SYSCALL_TRACE) { + const filters = new Set( + process.env.PHP_TEST_SYSCALL_TRACE.split(",") + .map((s) => s.trim()) + .filter(Boolean), + ); + host.subscribeSyscalls((event) => { + const name = ABI_SYSCALL_NAMES[event.nr] ?? `syscall_${event.nr}`; + if (filters.size === 0 || filters.has(name) || filters.has(String(event.nr))) { + console.error( + `[php-phpt-syscall t=${event.t.toFixed(3)}] pid=${event.pid} ${name}(${event.args.join(",")})`, + ); + } + }); + } + this.host = host; + return host; + } + + private async resetHost(host: NodeKernelHost): Promise { + if (this.host === host) this.host = null; + await host.destroy().catch(() => {}); + await delay(0); + } + + private async hasLiveProcesses(host: NodeKernelHost): Promise { + const processes = await withTimeout(host.enumProcs(), 1_000, "enumProcs"); + return processes.length > 0; + } + + private async terminateLiveProcesses(host: NodeKernelHost): Promise { + let processes: Array<{ pid: number }> = []; + try { + processes = await withTimeout(host.enumProcs(), 1_000, "enumProcs"); + } catch { + await this.resetHost(host); + return true; + } + if (processes.length === 0) return false; + const results = await Promise.allSettled( + processes.map((process) => + withTimeout( + host.terminateProcess(process.pid), + 1_000, + `terminate pid ${process.pid}`, + ), + ), + ); + if (results.some((result) => result.status === "rejected")) { + await this.resetHost(host); + return true; + } + return true; + } + + async runScript(opts: { + test: PhptTest; + kind: "skipif" | "file" | "clean"; + script: string; + argv: string[]; + scriptArgs?: string[]; + env: string[]; + stdin?: string; + waitForChildOutput?: boolean; + timeoutMs: number; + }): Promise { + const scriptName = phptGeneratedScriptName(opts.test, opts.kind); + const hostScriptPath = nodeTempPath(opts.test, this.sourceRoot, scriptName); + const scriptPath = guestScriptPath(opts.test, this.sourceRoot, scriptName); + const previousScript = existsSync(hostScriptPath) + ? readFileSync(hostScriptPath) + : null; + writeFileSync(hostScriptPath, opts.script, "latin1"); + const start = performance.now(); + const host = await this.ensureHost(); + if (!this.phpBytes) throw new Error("PHP wasm bytes not loaded"); + const output = { stdout: "", stderr: "", output: "" }; + this.activeOutput = output; + // PHPT execution is non-interactive: finite stdin and captured stdout and + // stderr are all created as pipes by the host before the process starts. + // Tests without an explicit --STDIN-- section receive immediate EOF. + const stdin = Buffer.from(opts.stdin ?? "", "latin1"); + let timeoutId: ReturnType | undefined; + let pid: number | null = null; + try { + const exitPromise = host.spawn( + this.phpBytes, + [ + this.virtualPhpPath, + ...opts.argv, + scriptPath, + ...(opts.scriptArgs ?? []), + ], + { + // php-src run-tests.php executes generated test files from the + // source root. Several PHPTs intentionally use source-root-relative + // paths such as ./ext/standard/tests/file. + cwd: "/php-src", + env: [ + "HOME=/tmp", + "USER=kandelo", + "USERNAME=kandelo", + "LOGNAME=kandelo", + "TMPDIR=/tmp", + "PATH=/bin:/usr/bin:/usr/local/bin", + `TEST_PHP_SRCDIR=/php-src`, + `TEST_PHP_EXECUTABLE=${this.virtualPhpPath}`, + `TEST_PHP_EXECUTABLE_ESCAPED=${shellEscape(this.virtualPhpPath)}`, + ...opts.env, + ], + stdin, + uid: this.runUid, + gid: this.runGid, + onStarted: (startedPid) => { + pid = startedPid; + }, + }, + ); + const timeoutPromise = new Promise((_, reject) => { + timeoutId = setTimeout( + () => reject(new Error("TIMEOUT")), + opts.timeoutMs, + ); + }); + const exitCode = await Promise.race([exitPromise, timeoutPromise]); + // A PHP process may fork short-lived children that inherit the same + // stdio and produce PHPT-observed output after the original parent exits + // (matching native run-tests.php process-tree behavior). Only enable + // this bounded grace period for PHPTs that actually exercise fork-like + // APIs; doing it unconditionally would add seconds to every test. + if (opts.waitForChildOutput) { + await delay(1_000); + } + // Process exit and stdio notifications are delivered over separate host + // messages. Wait for output to quiesce before freezing the capture so + // data written immediately before _exit() is not lost. A fixed short + // sleep still flaked on buffered CLI/file PHPTs under full-suite load. + let lastOutputLength = -1; + let stablePolls = 0; + for (let waitedMs = 0; waitedMs < 500 && stablePolls < 3; waitedMs += 25) { + await delay(25); + const outputLength = output.output.length; + if (waitedMs >= 100 && outputLength === lastOutputLength) { + stablePolls++; + } else { + stablePolls = 0; + } + lastOutputLength = outputLength; + } + return { + exitCode, + stdout: output.stdout, + stderr: output.stderr, + output: output.output, + durationMs: Math.round(performance.now() - start), + }; + } catch (err: any) { + const message = err?.message || String(err); + if (message.includes("TIMEOUT") && pid !== null) { + await this.resetHost(host); + } + return { + exitCode: -1, + stdout: output.stdout, + stderr: output.stderr, + output: output.output, + error: message.includes("TIMEOUT") ? "TIMEOUT" : message, + durationMs: Math.round(performance.now() - start), + }; + } finally { + if (this.host === host) { + const hadLiveProcesses = await this.terminateLiveProcesses(host); + // A PHPT that leaves children behind can also leave pipes, sockets, or + // stdio delivery state behind. Upstream run-tests.php gets a fresh OS + // process tree for every PHP invocation; mirror that isolation when + // Kandelo reports leftover processes after a section completes. + if (hadLiveProcesses && this.host === host) { + await this.resetHost(host); + } + } + this.activeOutput = null; + if (timeoutId) clearTimeout(timeoutId); + forceNodeGc(); + if (previousScript) { + writeFileSync(hostScriptPath, previousScript); + } else { + rmSync(hostScriptPath, { force: true }); + } + } + } + + async endTest(): Promise { + if (this.hostResetInterval <= 0 || !this.host) return; + this.testsSinceReset++; + if (this.testsSinceReset < this.hostResetInterval) return; + const host = this.host; + this.testsSinceReset = 0; + await this.resetHost(host); + } + + async close(): Promise { + const host = this.host; + this.host = null; + if (host) await host.destroy().catch(() => {}); + if (this.ownsSourceRoot) { + rmSync(this.sourceRoot, { recursive: true, force: true }); + } + if (this.extensionMountRoot) { + rmSync(this.extensionMountRoot, { recursive: true, force: true }); + this.extensionMountRoot = null; + } + if (this.binaryMountRoot) { + rmSync(this.binaryMountRoot, { recursive: true, force: true }); + this.binaryMountRoot = null; + } + } +} + +function copySourceRootForNodeRunner(sourceRoot: string, index: number): string { + const copyRoot = mkdtempSync(join(tmpdir(), `kandelo-php-src-${index}-`)); + rmSync(copyRoot, { recursive: true, force: true }); + try { + cpSync(sourceRoot, copyRoot, { + recursive: true, + dereference: false, + filter: (path) => { + const base = basename(path); + return base !== ".git" && base !== ".deps" && base !== ".libs"; + }, + }); + // On macOS, tmpdir() returns /tmp while the filesystem canonicalizes it + // to /private/tmp. Keep the PHPT root and discovered test paths in the + // same namespace so relative guest paths never acquire ../ components. + return realpathSync(copyRoot); + } catch (err) { + rmSync(copyRoot, { recursive: true, force: true }); + throw err; + } +} + +function makeSourceTreeWritableByGuest(sourceRoot: string): void { + const stack = [sourceRoot]; + while (stack.length > 0) { + const current = stack.pop()!; + const st = lstatSync(current); + if (st.isSymbolicLink()) continue; + if (st.isDirectory()) { + chmodSync(current, 0o777); + for (const entry of readdirSync(current)) { + stack.push(join(current, entry)); + } + } else { + // Non-root PHPT runs still generate per-test .php/.ini/.log fixtures in + // the mounted php-src checkout. Make the copied fixture tree writable + // to the guest user instead of weakening kernel credential checks. + chmodSync(current, (st.mode & 0o111) | 0o666); + } + } +} + +function phpTestVfsPublicUrl(): string { + const rel = relative(BROWSER_PUBLIC_DIR, PHP_TEST_VFS); + if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) { + throw new Error( + `PHP_TEST_VFS_OUT must be inside ${BROWSER_PUBLIC_DIR} for browser runs: ${PHP_TEST_VFS}`, + ); + } + return rel.split(sep).join("/"); +} + +async function startViteServer(): Promise { + const vfsPublicUrl = phpTestVfsPublicUrl(); + return new Promise((resolvePromise, reject) => { + const viteBin = join(BROWSER_DIR, "node_modules", ".bin", "vite"); + const useLocalVite = existsSync(viteBin); + const proc = spawn( + useLocalVite ? viteBin : "npx", + [ + ...(useLocalVite ? [] : ["vite"]), + "--config", + join(BROWSER_DIR, "vite.config.ts"), + "--host", + VITE_HOST, + "--port", + String(VITE_PORT), + "--strictPort", + ], + { + cwd: BROWSER_DIR, + stdio: ["ignore", "pipe", "pipe"], + env: { + ...process.env, + KANDELO_BROWSER_DEMO_INPUTS: "php-test", + VITE_PHP_TEST_VFS_URL: vfsPublicUrl, + }, + }, + ); + let started = false; + let stdout = ""; + let stderr = ""; + const timeout = setTimeout(() => { + if (!started) { + proc.kill(); + reject( + new Error( + `Vite server did not start within 30s${ + stdout || stderr + ? `: +${stdout}${stderr}` + : "" + }`, + ), + ); + } + }, 30_000); + proc.stderr!.on("data", (data: Buffer) => { + stderr += data.toString(); + }); + proc.stdout!.on("data", (data: Buffer) => { + stdout += data.toString(); + if ( + !started && + (stdout.includes("Local:") || stdout.includes("ready in")) + ) { + started = true; + clearTimeout(timeout); + setTimeout(() => resolvePromise(proc), 500); + } + }); + proc.on("exit", (code) => { + if (!started) { + clearTimeout(timeout); + reject( + new Error( + `Vite exited with code ${code}${ + stderr + ? `: +${stderr}` + : "" + }`, + ), + ); + } + }); + }); +} + +class BrowserPhpRunner implements PhpRunner { + private vite: ChildProcess | null = null; + private browser: Browser | null = null; + private page: Page | null = null; + private activeTestId: string | null = null; + private activeTestSections = 0; + + constructor( + private sourceRoot: string, + private rebuildVfs: boolean, + private availableSharedExtensions: Set, + private ownsSourceRoot = false, + private runUid?: number, + private runGid?: number, + ) {} + + loadExtensionIniArgs(requiredExtensions: string[]): string[] { + return loadExtensionIniArgs( + requiredExtensions, + this.availableSharedExtensions, + BROWSER_EXTENSION_DIR, + ); + } + + async init(): Promise { + const builder = join( + REPO_ROOT, + "images/vfs/scripts/build-php-test-vfs-image.sh", + ); + const builderEnv = { ...process.env, PHP_SOURCE_DIR: this.sourceRoot }; + const expectedFingerprint = execFileSync( + "bash", + [builder, "--print-fingerprint"], + { + cwd: REPO_ROOT, + env: builderEnv, + encoding: "utf8", + }, + ).trim(); + let recordedFingerprint: string | undefined; + try { + const metadata = JSON.parse( + readFileSync(`${PHP_TEST_VFS}.meta.json`, "utf8"), + ); + if (metadata?.version === 1 && typeof metadata.fingerprint === "string") { + recordedFingerprint = metadata.fingerprint; + } + } catch { + recordedFingerprint = undefined; + } + if ( + this.rebuildVfs || + !existsSync(PHP_TEST_VFS) || + recordedFingerprint !== expectedFingerprint + ) { + execFileSync( + "bash", + [builder], + { + cwd: REPO_ROOT, + stdio: "inherit", + env: builderEnv, + }, + ); + } + this.vite = await startViteServer(); + await this.launchBrowser(); + await this.reloadPage(); + } + + private async launchBrowser(): Promise { + await this.browser?.close().catch(() => {}); + this.browser = await chromium.launch({ + executablePath: process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH, + args: [ + "--enable-features=SharedArrayBuffer", + ...extraChromiumArgsFromEnv(), + ], + }); + } + + private async reloadPage(): Promise { + for (let attempt = 0; attempt < 2; attempt++) { + if (!this.browser || !this.browser.isConnected()) { + await this.launchBrowser(); + } + try { + const context = await this.browser!.newContext(); + this.page = await context.newPage(); + this.page.on("console", (msg) => { + if (msg.type() === "error") console.error(`[browser] ${msg.text()}`); + }); + await this.page.goto( + `http://${VITE_HOST}:${VITE_PORT}/pages/php-test/`, + ); + await this.page.waitForFunction( + () => (window as any).__phpTestReady === true, + {}, + { timeout: 120_000 }, + ); + return; + } catch (err) { + if (attempt === 0) { + await this.launchBrowser(); + continue; + } + throw err; + } + } + } + + async runScript(opts: { + test: PhptTest; + kind: "skipif" | "file" | "clean"; + script: string; + argv: string[]; + scriptArgs?: string[]; + env: string[]; + stdin?: string; + waitForChildOutput?: boolean; + timeoutMs: number; + }): Promise { + if (!this.page) throw new Error("browser page not ready"); + if (this.activeTestId !== opts.test.rel) { + this.activeTestId = opts.test.rel; + this.activeTestSections = 0; + } + const retryCanStartFromFreshImage = this.activeTestSections === 0; + + const scriptName = phptGeneratedScriptName(opts.test, opts.kind); + const scriptPath = guestScriptPath(opts.test, this.sourceRoot, scriptName); + const request = { + testId: opts.test.rel, + scriptPath, + script: opts.script, + argv: [...opts.argv, scriptPath, ...(opts.scriptArgs ?? [])], + cwd: "/php-src", + env: [ + "PATH=/bin:/usr/bin:/usr/local/bin", + "USER=kandelo", + "USERNAME=kandelo", + "LOGNAME=kandelo", + "TEST_PHP_SRCDIR=/php-src", + "TEST_PHP_EXECUTABLE=/usr/local/bin/php", + "TEST_PHP_EXECUTABLE_ESCAPED='/usr/local/bin/php'", + ...opts.env, + ], + uid: this.runUid, + gid: this.runGid, + stdin: opts.stdin ?? "", + waitForChildOutput: opts.waitForChildOutput, + timeoutMs: opts.timeoutMs, + }; + + for (let attempt = 0; attempt < 2; attempt++) { + const start = performance.now(); + try { + const page = this.page; + if (!page) throw new Error("browser page not ready"); + const evaluatePromise = page.evaluate( + async ({ request }) => (window as any).__runPhpScript(request), + { request }, + ); + void evaluatePromise.catch(() => {}); + const result = await withTimeout( + evaluatePromise, + opts.timeoutMs + 10_000, + "browser PHPT run", + ); + this.activeTestSections++; + return result; + } catch (err: any) { + const message = err?.message || String(err); + const timedOut = /browser PHPT run timed out/.test(message); + if (timedOut) { + await this.page + ?.context() + .close() + .catch(() => {}); + this.page = null; + await this.reloadPage().catch(() => {}); + return { + exitCode: -1, + stdout: "", + stderr: "", + error: "TIMEOUT", + durationMs: Math.round(performance.now() - start), + }; + } + const recoverable = + /Execution context was destroyed|Target page, context or browser has been closed|Navigation failed/i.test( + message, + ); + if (recoverable) { + await this.page + ?.context() + .close() + .catch(() => {}); + this.page = null; + try { + await this.reloadPage(); + } catch (reloadErr: any) { + return { + exitCode: -1, + stdout: "", + stderr: "", + error: reloadErr?.message || String(reloadErr), + durationMs: Math.round(performance.now() - start), + }; + } + if (attempt === 0 && retryCanStartFromFreshImage) { + continue; + } + } + return { + exitCode: -1, + stdout: "", + stderr: "", + error: message, + durationMs: Math.round(performance.now() - start), + }; + } + } + throw new Error("unreachable"); + } + + async endTest(): Promise { + this.activeTestId = null; + this.activeTestSections = 0; + } + + async close(): Promise { + if (this.page) + await this.page + .context() + .close() + .catch(() => {}); + if (this.browser) await this.browser.close().catch(() => {}); + if (this.vite) { + const vite = this.vite; + if (vite.exitCode === null && vite.signalCode === null) { + vite.kill("SIGTERM"); + } + await new Promise((resolveDone) => { + if (vite.exitCode !== null || vite.signalCode !== null) { + resolveDone(); + return; + } + const killTimer = setTimeout(() => { + if (vite.exitCode === null && vite.signalCode === null) { + vite.kill("SIGKILL"); + } + resolveDone(); + }, 2000); + vite.once("exit", () => { + clearTimeout(killTimer); + resolveDone(); + }); + }); + this.vite = null; + } + if (this.ownsSourceRoot) { + rmSync(this.sourceRoot, { recursive: true, force: true }); + } + } +} + +export async function probeLoadedExtensions( + runner: PhpRunner, + sourceRoot: string, + sharedExtensions: Set, + timeoutMs: number, +): Promise> { + const marker = "__KANDELO_PHP_EXTENSIONS__"; + const endMarker = "__KANDELO_PHP_EXTENSIONS_END__"; + const probe: PhptTest = { + path: join(sourceRoot, ".kandelo-extension-probe.phpt"), + rel: ".kandelo-extension-probe.phpt", + sourceRoot, + sections: {}, + }; + const result = await runner.runScript({ + test: probe, + kind: "file", + script: ``, + argv: [ + ...baseIniArgs(), + ...runner.loadExtensionIniArgs([...sharedExtensions]), + ], + env: defaultPhpTestEnvArgs(), + timeoutMs: Math.max(30_000, timeoutMs), + }); + const output = result.output ?? `${result.stdout}${result.stderr}`; + if (result.error || result.exitCode !== 0) { + throw new Error( + `PHP extension probe failed${result.error ? `: ${result.error}` : ` with exit ${result.exitCode}`}${ + output ? `; output: ${failureSnippet(output)}` : "" + }`, + ); + } + const start = output.lastIndexOf(marker); + const end = start < 0 ? -1 : output.indexOf(endMarker, start + marker.length); + if (start < 0 || end < 0) { + throw new Error( + `PHP extension probe returned malformed output: ${failureSnippet(output)}`, + ); + } + let parsed: unknown; + try { + parsed = JSON.parse(output.slice(start + marker.length, end)); + } catch (err: any) { + throw new Error( + `PHP extension probe returned invalid JSON: ${err?.message || String(err)}`, + ); + } + if (!Array.isArray(parsed) || parsed.some((name) => typeof name !== "string")) { + throw new Error("PHP extension probe did not return a string array"); + } + const loaded = new Set(parsed.map((name) => normalizeExtensionName(name))); + const missingShared = [...sharedExtensions].filter((name) => !loaded.has(name)); + if (missingShared.length > 0) { + throw new Error( + `packaged PHP extension(s) failed to load: ${missingShared.join(", ")}; ` + + `output: ${failureSnippet(output)}`, + ); + } + return loaded; +} + +export async function runPhpt( + test: PhptTest, + runner: PhpRunner, + availableExtensions: Set, + timeoutMs: number, +): Promise { + return runPhptAttempt(test, runner, availableExtensions, timeoutMs, false); +} + +function phptMayRetry(test: PhptTest, output: string): boolean { + if (test.sections.FLAKY !== undefined) return true; + const source = test.sections.FILE ?? ""; + if (/\b(?:disk_free_space|hrtime|microtime|sleep|usleep)\(/i.test(source)) { + return true; + } + return /\b(?:404: page not found|address already in use|connection refused|deadlock|mailbox already exists|timed out)\b/i + .test(normalizeOutput(output)); +} + +async function runPhptAttempt( + test: PhptTest, + runner: PhpRunner, + availableExtensions: Set, + timeoutMs: number, + retried: boolean, +): Promise { + const start = performance.now(); + const unsupported = unsupportedReason(test); + if (unsupported) { + return { + test: test.rel, + status: "unsupported", + time_ms: 0, + reason: unsupported, + }; + } + + const commonEnv = mergeEnvArgs( + passthroughEnvArgs(), + defaultPhpTestEnvArgs(), + envArgs(test.sections.ENV, test), + ); + const defaultIniArgs = baseIniArgs(); + const testIniArgs = iniArgs(test.sections.INI, test); + const args = splitArgs(test.sections.ARGS); + const requiredExtensions = extensionArgs(test.sections.EXTENSIONS); + const extensionIniArgs = runner.loadExtensionIniArgs(requiredExtensions); + const missingRequiredExtensions = requiredExtensions.filter( + (extension) => !availableExtensions.has(normalizeExtensionName(extension)), + ); + if (missingRequiredExtensions.length > 0) { + return { + test: test.rel, + status: "skip", + time_ms: Math.round(performance.now() - start), + reason: `skip required extension(s) not loaded: ${missingRequiredExtensions.join(", ")}`, + }; + } + const preTestArgv = [...extensionIniArgs, ...defaultIniArgs]; + const testArgv = [...preTestArgv, ...testIniArgs]; + const envWithExtraArgs = [ + ...commonEnv, + `TEST_PHP_EXTRA_ARGS=${shellArgs(testArgv)}`, + ]; + let skipXfailReason: string | undefined; + let skipWarning: string | undefined; + let skipInfo: string | undefined; + let skipFlakyReason: string | undefined; + let skipXleakReason: string | undefined; + if (test.sections.SKIPIF !== undefined) { + const skip = await runner.runScript({ + test, + kind: "skipif", + script: test.sections.SKIPIF, + // Upstream run-tests.php executes SKIPIF before applying the test's + // --INI-- block. Keep that ordering so resource-probing SKIPIF sections + // are not distorted by settings meant only for the main FILE body. + argv: preTestArgv, + env: envForSection(test, "skipif", envWithExtraArgs), + timeoutMs, + }); + // php-src's system_with_timeout() reads stdout when evaluating SKIPIF and + // leaves the separately captured stderr pipe out of the verdict string. + // Preserve interleaved output for diagnostics, but do not let a shell + // diagnostic emitted on stderr turn a valid stdout `skip` into BORK. + let skipOutput = normalizeOutput(skip.stdout); + const skipDiagnostics = normalizeOutput( + skip.output ?? `${skip.stdout}${skip.stderr}`, + ); + // SkipCache in php-src treats a leading lowercase `nocache` directive as + // empty output after using it to disable cache reuse for that SKIPIF. + if (skipOutput.startsWith("nocache")) skipOutput = ""; + if (skip.error) { + return { + test: test.rel, + status: skip.error === "TIMEOUT" ? "time" : "fail", + time_ms: skip.durationMs, + reason: + skip.error === "TIMEOUT" + ? "SKIPIF timed out" + : `SKIPIF host error: ${skip.error}`, + detail: skipDiagnostics + ? `partial output: ${failureSnippet(skipDiagnostics)}` + : undefined, + }; + } + if (/^(?:skip|skipped)\b/i.test(skipOutput)) { + return { + test: test.rel, + status: "skip", + time_ms: Math.round(performance.now() - start), + reason: skipOutput, + }; + } + const infoMatch = skipOutput.match(/^info\s*(.+)/i); + const warnMatch = skipOutput.match(/^warn\s+(.+)/i); + if (infoMatch) { + skipInfo = infoMatch[1]; + } else if (warnMatch) { + skipWarning = warnMatch[1]; + } else if (/^xfail/i.test(skipOutput)) { + // Match run-tests.php: SKIPIF may synthesize an XFAIL section, but FILE + // still runs and determines whether the result is XFAIL or XPASS. + skipXfailReason = skipOutput.slice(5).trim(); + } else if (/^xleak/i.test(skipOutput)) { + skipXleakReason = skipOutput.slice(5).trim(); + } else if (/^flaky/i.test(skipOutput)) { + skipFlakyReason = skipOutput.slice(5).trim(); + } else if (skipOutput !== "") { + return { + test: test.rel, + status: "bork", + time_ms: Math.round(performance.now() - start), + reason: "invalid output from SKIPIF", + detail: failureSnippet(skipOutput), + }; + } + } + + const runMain = () => + runner.runScript({ + test, + kind: "file", + script: testScript(test), + argv: testArgv, + scriptArgs: args, + env: envForSection(test, "file", envWithExtraArgs), + stdin: test.sections.STDIN, + waitForChildOutput: /\b(?:pcntl_fork|pcntl_rfork|forkx|proc_open|popen)\s*\(/.test( + test.sections.FILE ?? "", + ), + timeoutMs, + }); + + const main = await runMain(); + + let ok = false; + let detail = main.error; + let actualOutput = main.output ?? `${main.stdout}${main.stderr}`; + if (main.error) { + detail = main.error; + if (actualOutput) { + detail += `; partial actual: ${failureSnippet(actualOutput)}`; + } + } else { + const compared = compareExpectation(test, actualOutput); + // PHPTs often intentionally trigger fatal errors; upstream run-tests.php + // treats matching output as the authority rather than requiring exit 0. + ok = compared.ok; + detail = compared.detail; + if (!ok && detail) { + const snippet = failureSnippet(actualOutput); + detail = `${detail}; exit=${main.exitCode}; actual: ${snippet}`; + } + } + const mainMatched = ok; + + let cleanFailure: string | undefined; + let cleanTimedOut = false; + let cleanBorked = false; + if (test.sections.CLEAN !== undefined) { + try { + const clean = await runner.runScript({ + test, + kind: "clean", + script: test.sections.CLEAN, + // CLEAN runs with the same pre-test INI baseline as SKIPIF upstream. + argv: preTestArgv, + env: envForSection(test, "clean", envWithExtraArgs), + timeoutMs: Math.min(timeoutMs, 30_000), + }); + const cleanOutput = normalizeOutput( + clean.output ?? `${clean.stdout}${clean.stderr}`, + ); + if (clean.error || clean.exitCode !== 0) { + cleanTimedOut = clean.error === "TIMEOUT"; + cleanFailure = clean.error + ? `CLEAN host error: ${clean.error}` + : `CLEAN exited ${clean.exitCode}`; + if (cleanOutput) { + cleanFailure += `; output: ${failureSnippet(cleanOutput)}`; + } + } else if (cleanOutput && mainMatched) { + // run-tests.php treats output from an otherwise-successful CLEAN + // section as a malformed test, not a passing cleanup. + cleanBorked = true; + cleanFailure = `invalid output from CLEAN: ${failureSnippet(cleanOutput)}`; + } + } catch (err: any) { + cleanFailure = `CLEAN harness error: ${err?.message || String(err)}`; + } + } + if ( + !retried && + !main.error && + !cleanFailure && + !mainMatched && + (skipFlakyReason !== undefined || phptMayRetry(test, actualOutput)) + ) { + // php-src reruns the complete PHPT once, including SKIPIF and CLEAN. Do + // not let a retry hide host, timeout, or cleanup infrastructure failures. + return runPhptAttempt(test, runner, availableExtensions, timeoutMs, true); + } + if (cleanFailure) { + ok = false; + detail = detail ? `${detail}; ${cleanFailure}` : cleanFailure; + } + + const isXfail = + test.sections.XFAIL !== undefined || skipXfailReason !== undefined; + const xfailReason = + test.sections.XFAIL !== undefined + ? normalizeOutput(test.sections.XFAIL) + : skipXfailReason; + const skipContext = + skipInfo !== undefined + ? `SKIPIF info: ${skipInfo}` + : skipFlakyReason !== undefined + ? `SKIPIF flaky: ${skipFlakyReason || "no reason given"}` + : skipXleakReason !== undefined + ? `SKIPIF xleak: ${skipXleakReason || "no reason given"}` + : undefined; + let status: TestStatus; + if (cleanBorked) status = "bork"; + else if (cleanFailure) status = cleanTimedOut ? "time" : "fail"; + else if (main.error === "TIMEOUT") status = "time"; + else if (main.error) status = "fail"; + // PHP 8.3's run-tests.php reports WARN for a matching test after a warned + // SKIPIF. A mismatch is a combined WARN&FAIL upstream; this single-status + // harness must retain FAIL so the aggregate command cannot exit success. + else if (skipWarning !== undefined && ok) status = "warn"; + else if (ok) status = isXfail ? "xpass" : retried ? "warn" : "pass"; + else status = isXfail ? "xfail" : "fail"; + + const warningContext = skipWarning + ? `SKIPIF warning: ${skipWarning}` + : undefined; + + return { + test: test.rel, + status, + time_ms: Math.round(performance.now() - start), + reason: + status === "xfail" + ? [xfailReason || "expected failure", warningContext] + .filter((value): value is string => value !== undefined) + .join("; ") + : status === "warn" + ? retried && ok + ? "test passed on retry attempt" + : [warningContext, !ok && isXfail ? xfailReason : undefined] + .filter((value): value is string => value !== undefined) + .join("; ") + : warningContext ?? skipContext, + detail, + }; +} + +function printUsage(): void { + console.error(`Usage: npx tsx scripts/run-php-upstream-tests.ts [options] [test-or-dir ...] + +Options: + --host node|browser Host runtime to use (default: node) + --all Run every .phpt test under php-src (default when no tests are passed) + --timeout Per PHPT section timeout (default: 60000) + --shard / Run 1-based shard i of n after discovery sorting + --offset Skip the first n selected tests + --limit Run only the first n discovered tests + --jobs Number of PHPTs to run concurrently (Node host only; default: 1) + --run-uid Run guest PHP processes as uid n + (default: PHP_TEST_RUN_UID; root when unset) + --run-gid Run guest PHP processes as gid n + (default: PHP_TEST_RUN_GID; root when unset) + --allow-unsupported Keep an inventory run successful when its only + non-passing results are unsupported harness features + --host-reset-interval + Reboot each Node-host Kandelo kernel after n PHPTs + per worker to reclaim host-side Wasm memory + (default: PHP_TEST_HOST_RESET_INTERVAL or 50; 0 disables) + --disable-tcp-network Disable Node-host outbound TCP/DNS bridging + (enabled by default; set PHP_TEST_ENABLE_TCP_NETWORK=0 + for the same effect) + --json Emit JSON lines + --report Write docs/php-upstream-test-report.md + --rebuild-vfs Rebuild php-test.vfs.zst before browser runs + --print-source-dir Resolve php-src from package metadata/cache and exit + +Environment: + PHP_WASM Path to php.wasm + PHP_FPM_WASM Optional path to php-fpm.wasm for FPM PHPTs + PHP_EXTENSION_DIR Additional directory/directories to scan for shared + extensions when PHP_WASM is outside the package bin dir + PHP_SOURCE_DIR Path to a php-src checkout/extract + PHP_TEST_RUN_UID Optional guest uid for PHP processes + PHP_TEST_RUN_GID Optional guest gid for PHP processes +`); +} + +async function main() { + // Upstream run-tests.php expects TEST_NON_ROOT_USER to be available for + // root-run preloading tests that use --INI-- placeholders before the guest + // process is spawned. Provide the portable account that Kandelo rootfs/VFS + // images carry by default rather than requiring every harness invocation to + // remember this environment variable. + process.env.TEST_NON_ROOT_USER ??= "nobody"; + + const args = process.argv.slice(2); + let host: HostKind = "node"; + let timeoutMs = 60_000; + let shard: { index: number; total: number } | null = null; + let offset = 0; + let limit: number | null = null; + let jobs = 1; + let runUid = parseOptionalNonNegativeInt(process.env.PHP_TEST_RUN_UID, "PHP_TEST_RUN_UID"); + let runGid = parseOptionalNonNegativeInt(process.env.PHP_TEST_RUN_GID, "PHP_TEST_RUN_GID"); + let hostResetInterval = parseInt( + process.env.PHP_TEST_HOST_RESET_INTERVAL ?? "50", + 10, + ); + let enableTcpNetwork = process.env.PHP_TEST_ENABLE_TCP_NETWORK !== "0"; + let json = false; + let report = false; + let rebuildVfs = false; + let allowUnsupported = false; + let printSourceDir = false; + const selectors: string[] = []; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + if (arg === "--help" || arg === "-h") { + printUsage(); + return; + } else if (arg === "--host" && args[i + 1]) { + const value = args[++i]; + if (value !== "node" && value !== "browser") + throw new Error(`invalid host: ${value}`); + host = value; + } else if (arg === "--all") { + // Default mode; accepted for clarity. + } else if (arg === "--timeout" && args[i + 1]) { + timeoutMs = parseInt(args[++i], 10); + } else if (arg === "--shard" && args[i + 1]) { + const value = args[++i]; + const match = /^(\d+)\/(\d+)$/.exec(value); + if (!match) throw new Error(`invalid shard: ${value}`); + shard = { + index: parseInt(match[1], 10), + total: parseInt(match[2], 10), + }; + if (shard.total < 1 || shard.index < 1 || shard.index > shard.total) { + throw new Error(`invalid shard: ${value}`); + } + } else if (arg === "--offset" && args[i + 1]) { + offset = parseInt(args[++i], 10); + if (!Number.isFinite(offset) || offset < 0) { + throw new Error(`invalid offset: ${offset}`); + } + } else if (arg === "--limit" && args[i + 1]) { + limit = parseInt(args[++i], 10); + if (!Number.isFinite(limit) || limit < 0) { + throw new Error(`invalid limit: ${limit}`); + } + } else if (arg === "--jobs" && args[i + 1]) { + jobs = parseInt(args[++i], 10); + if (!Number.isFinite(jobs) || jobs < 1) { + throw new Error(`invalid jobs: ${jobs}`); + } + } else if (arg === "--run-uid" && args[i + 1]) { + runUid = parseOptionalNonNegativeInt(args[++i], "--run-uid"); + } else if (arg === "--run-gid" && args[i + 1]) { + runGid = parseOptionalNonNegativeInt(args[++i], "--run-gid"); + } else if (arg === "--host-reset-interval" && args[i + 1]) { + hostResetInterval = parseInt(args[++i], 10); + if (!Number.isFinite(hostResetInterval) || hostResetInterval < 0) { + throw new Error(`invalid host reset interval: ${hostResetInterval}`); + } + } else if (arg === "--disable-tcp-network") { + enableTcpNetwork = false; + } else if (arg === "--json") { + json = true; + } else if (arg === "--report") { + report = true; + } else if (arg === "--rebuild-vfs") { + rebuildVfs = true; + } else if (arg === "--allow-unsupported") { + allowUnsupported = true; + } else if (arg === "--print-source-dir") { + printSourceDir = true; + } else if (arg.startsWith("--")) { + throw new Error(`unknown option: ${arg}`); + } else { + selectors.push(arg); + } + } + + if (printSourceDir) { + console.log(resolvePhpSource()); + return; + } + + const sourceInputRoot = resolvePhpSource(); + // Fixture maintenance and generated PHPT section files must never modify a + // caller-supplied or content-addressed php-src checkout. Prepare one + // harness-owned source tree, then give parallel Node runners their own + // copies of that already-prepared tree. + const sourceRoot = copySourceRootForNodeRunner(sourceInputRoot, 0); + const runners: PhpRunner[] = []; + try { + preparePhpTestFixtures( + sourceRoot, + join(REPO_ROOT, "tests/php-fixtures"), + ); + const phpPath = resolvePhpBinary(); + const phpFpmPath = resolvePhpFpmBinary(phpPath); + const sharedExtensionPaths = sharedExtensionPathsForPhp(phpPath); + const availableSharedExtensions = new Set(sharedExtensionPaths.keys()); + let tests = discoverTests(sourceRoot, selectors); + if (shard !== null) { + tests = tests.filter((_, idx) => idx % shard!.total === shard!.index - 1); + } + if (offset > 0) tests = tests.slice(offset); + if (limit !== null) tests = tests.slice(0, limit); + if (host === "browser" && jobs !== 1) { + throw new Error("--jobs is currently supported only by the node host"); + } + + if (!json) { + console.error("===== PHP PHPT runtime tests ====="); + console.error(`Host: ${host}`); + console.error(`php-src input: ${sourceInputRoot}`); + console.error(`php-src harness copy: ${sourceRoot}`); + console.error(`PHP wasm: ${phpPath}`); + if (phpFpmPath) { + console.error(`PHP-FPM wasm: ${phpFpmPath}`); + } + if (availableSharedExtensions.size > 0) { + console.error( + `Shared extensions: ${[...availableSharedExtensions].join(", ")}`, + ); + } + if (shard !== null) { + console.error(`Shard: ${shard.index}/${shard.total}`); + } + if (offset > 0) console.error(`Offset: ${offset}`); + if (jobs > 1) console.error(`Jobs: ${jobs}`); + if (host === "node") { + console.error(`Node host reset interval: ${hostResetInterval}`); + console.error( + `Node TCP/DNS bridge: ${enableTcpNetwork ? "enabled" : "disabled"}`, + ); + } + if (runUid !== undefined || runGid !== undefined) { + console.error( + `Guest credentials: uid=${runUid ?? 0} gid=${runGid ?? runUid ?? 0}`, + ); + } + console.error(`Tests: ${tests.length}`); + console.error(""); + } + + if (host === "browser") { + const runner = new BrowserPhpRunner( + sourceRoot, + rebuildVfs, + availableSharedExtensions, + true, + runUid, + runGid, + ); + runners.push(runner); + await runner.init(); + } else { + for (let i = 0; i < jobs; i++) { + const runnerSourceRoot = + i === 0 ? sourceRoot : copySourceRootForNodeRunner(sourceRoot, i + 1); + const runner = new NodePhpRunner( + runnerSourceRoot, + phpPath, + phpFpmPath, + sharedExtensionPaths, + true, + hostResetInterval, + enableTcpNetwork, + runUid, + runGid, + ); + runners.push(runner); + if (runUid !== undefined || runGid !== undefined) { + makeSourceTreeWritableByGuest(runnerSourceRoot); + } + } + } + const availableExtensions = await probeLoadedExtensions( + runners[0], + sourceRoot, + availableSharedExtensions, + timeoutMs, + ); + if (!json) { + console.error( + `Loaded extensions: ${[...availableExtensions].sort().join(", ")}`, + ); + console.error(""); + } + + const counts: Record = { + pass: 0, + fail: 0, + bork: 0, + warn: 0, + skip: 0, + xfail: 0, + xpass: 0, + unsupported: 0, + time: 0, + }; + const results: TestResult[] = new Array(tests.length); + let completed = 0; + const pendingTests = new Set(tests.map((_test, index) => index)); + const activeConflicts = new Set(); + let activeTests = 0; + let exclusiveActive = false; + let schedulerWaiters: Array<() => void> = []; + + async function acquireTest(): Promise<{ + index: number; + conflicts: string[]; + } | null> { + while (true) { + if (pendingTests.size === 0) return null; + for (const index of pendingTests) { + const conflicts = phptConflictTokens(tests[index]); + const exclusive = requiresExclusiveScheduling(conflicts); + if (exclusiveActive || (exclusive && activeTests > 0)) { + continue; + } + if (conflicts.some((conflict) => activeConflicts.has(conflict))) { + continue; + } + pendingTests.delete(index); + for (const conflict of conflicts) activeConflicts.add(conflict); + activeTests++; + if (exclusive) exclusiveActive = true; + return { index, conflicts }; + } + await new Promise((resolve) => schedulerWaiters.push(resolve)); + } + } + + function releaseTest(conflicts: string[]) { + for (const conflict of conflicts) activeConflicts.delete(conflict); + if (requiresExclusiveScheduling(conflicts)) exclusiveActive = false; + activeTests = Math.max(0, activeTests - 1); + const waiters = schedulerWaiters; + schedulerWaiters = []; + for (const wake of waiters) wake(); + } + + await Promise.all( + runners.map(async (runner) => { + while (true) { + const acquired = await acquireTest(); + if (acquired === null) break; + const { index, conflicts } = acquired; + let result: TestResult; + try { + result = await runPhpt( + tests[index], + runner, + availableExtensions, + timeoutMs, + ); + } finally { + releaseTest(conflicts); + } + counts[result.status]++; + results[index] = result; + completed++; + await runner.endTest?.(); + if (json) { + console.log(JSON.stringify(result)); + } else { + const label = result.status.toUpperCase().padEnd(11); + console.error( + `[${completed}/${tests.length}] ${label} ${result.test} (${result.time_ms}ms)`, + ); + } + } + }), + ); + + const completedResults = results.filter((result): result is TestResult => { + return result !== undefined; + }); + if (completedResults.length !== tests.length) { + for (let i = 0; i < tests.length; i++) { + if (results[i] === undefined) { + const result: TestResult = { + test: tests[i].rel, + status: "time", + time_ms: 0, + reason: "harness did not record a result", + }; + results[i] = result; + counts.time++; + } + } + } + + if (report) { + const reportPath = join(REPO_ROOT, "docs/php-upstream-test-report.md"); + mkdirSync(dirname(reportPath), { recursive: true }); + const lines = [ + "# PHP PHPT Runtime Test Report", + "", + `Host: ${host}`, + `Generated: ${new Date().toISOString()}`, + "", + "| Status | Count |", + "|--------|-------|", + ...Object.entries(counts).map( + ([status, count]) => `| ${status.toUpperCase()} | ${count} |`, + ), + `| **TOTAL** | **${results.length}** |`, + "", + "## Non-Passing Results", + "", + ...results + .filter((r) => !["pass", "skip", "xfail"].includes(r.status)) + .map( + (r) => + `- ${r.status.toUpperCase()} \`${r.test}\`${r.reason ? `: ${r.reason}` : ""}${r.detail ? ` (${r.detail})` : ""}`, + ), + "", + ]; + writeFileSync(reportPath, `${lines.join("\n")}\n`); + if (!json) console.error(`Report written to: ${reportPath}`); + } + + if (!json) { + console.error(""); + console.error("===== Results ====="); + for (const status of [ + "pass", + "fail", + "bork", + "warn", + "skip", + "xfail", + "xpass", + "unsupported", + "time", + ] as const) { + console.error(`${status.toUpperCase().padEnd(11)} ${counts[status]}`); + } + console.error(`TOTAL ${results.length}`); + } + + if ( + counts.fail > 0 || + counts.bork > 0 || + counts.xpass > 0 || + (!allowUnsupported && counts.unsupported > 0) || + counts.time > 0 + ) { + process.exitCode = 1; + } + } finally { + await Promise.all( + runners.map((runner) => + runner.close().catch(() => { + // Keep shutdown best-effort so one wedged worker does not hide + // already-recorded PHPT results or other cleanup. + }), + ), + ); + rmSync(sourceRoot, { recursive: true, force: true }); + } +} + +if ( + process.argv[1] && + resolve(process.argv[1]) === fileURLToPath(import.meta.url) +) { + main().catch((err) => { + console.error(err); + process.exitCode = 1; + }); +} diff --git a/scripts/run-sortix-tests.sh b/scripts/run-sortix-tests.sh index 1dadac835a..d1c3eead4e 100755 --- a/scripts/run-sortix-tests.sh +++ b/scripts/run-sortix-tests.sh @@ -38,10 +38,12 @@ 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 + "pthread/pthread_getcpuclockid" # encoded per-thread CPU clocks are not yet + # recognized by clock_gettime (EINVAL) + "syslog/closelog" "syslog/syslog" # no /dev/log receiver; keep that boundary visible + "unistd/fpathconf" "unistd/pathconf" # _PC_FILESIZEBITS is truthfully indeterminate until + # the selected VFS backend can prove a bit width "strings/ffsll" # wasm32 test bug (long vs long long) # aio/aio_cancel was flaky (FAIL once, XPASS next run) — left # off this list; if it starts failing reliably, add it back. @@ -657,12 +659,22 @@ _run_runtime_test_worker() { # Run with timeout. KERNEL_CWD is the data directory containing symlinks # to test binaries at their expected relative paths (e.g., fcntl/open). local output rc + local result_base="${test_name//\//__}" + local guest_output_file="$result_dir/${result_base}.guest-output" + local host_diagnostic_file="$result_dir/${result_base}.host-diagnostics" + rm -f "$guest_output_file" "$host_diagnostic_file" # stdin redirected to /dev/null: run-example.ts reads process.stdin # when not a TTY, which would drain any pipe the caller supplies. set +e - output=$(cd "$REPO_ROOT" && KERNEL_CWD="${SORTIX_DATA_DIR:-$REPO_ROOT}" run_with_timeout "$this_timeout" node --experimental-wasm-exnref --import tsx/esm examples/run-example.ts "${wasm}" &1) + (cd "$REPO_ROOT" && \ + KERNEL_CWD="${SORTIX_DATA_DIR:-$REPO_ROOT}" \ + KANDELO_GUEST_OUTPUT_FILE="$guest_output_file" \ + run_with_timeout "$this_timeout" node --experimental-wasm-exnref \ + --import tsx/esm examples/run-example.ts "${wasm}" \ + "$host_diagnostic_file" 2>&1) rc=$? set -e + output=$(cat "$guest_output_file" 2>/dev/null || true) # Clean up .so symlink [ -n "$so_link" ] && rm -f "$so_link" 2>/dev/null || true @@ -685,6 +697,7 @@ exit: $rc" local local_expect_dir="$OS_TEST_LOCAL/${suite}.expect" local default_expect_dir="$OS_TEST/${suite}.expect" local override_expect_dir="$REPO_ROOT/tests/sortix/os-test-overrides/${suite}.expect" + local additional_expect_dir="$REPO_ROOT/tests/sortix/os-test-overrides/${suite}.expect-additional" local -a expect_dirs=() local override_has_expect=false local local_has_expect=false @@ -713,6 +726,17 @@ exit: $rc" elif [ -d "$default_expect_dir" ]; then expect_dirs=("$default_expect_dir") fi + # Add narrowly documented Kandelo outcomes without shadowing the upstream + # cross-platform alternatives. Existing *.expect override directories + # continue to replace upstream candidates when an invariant demands it. + if [ -d "$additional_expect_dir" ]; then + for expect_file in "$additional_expect_dir/${expect_base}.posix" "$additional_expect_dir/${expect_base}.posix."* "$additional_expect_dir/${expect_base}."[0-9]* "$additional_expect_dir/${expect_base}.unknown."*; do + if [ -f "$expect_file" ]; then + expect_dirs+=("$additional_expect_dir") + break + fi + done + fi local test_passed=false if [ $rc -eq 124 ]; then @@ -810,6 +834,30 @@ _print_result_output() { tail -n "$max_lines" "$result_file" | sed 's/^/ /' } +_print_host_diagnostics() { + local suite="$1" + local test_name="$2" + local result_dir="$3" + local diagnostic_file="$result_dir/${test_name//\//__}.host-diagnostics" + local max_lines="${SORTIX_HOST_DIAGNOSTIC_LINES:-40}" + + [ -s "$diagnostic_file" ] || return 0 + echo "HOST ${suite}/${test_name}" + local diagnostic_lines + diagnostic_lines=$(wc -l < "$diagnostic_file" | tr -d ' ') + if [ "$diagnostic_lines" -le "$max_lines" ]; then + sed 's/^/ /' "$diagnostic_file" + return + fi + + local first_lines=$((max_lines / 2)) + local last_lines=$((max_lines - first_lines)) + echo " --- host diagnostics: first ${first_lines} of ${diagnostic_lines} lines ---" + head -n "$first_lines" "$diagnostic_file" | sed 's/^/ /' + echo " --- host diagnostics: last ${last_lines} of ${diagnostic_lines} lines ---" + tail -n "$last_lines" "$diagnostic_file" | sed 's/^/ /' +} + # Collect one test result from result file into RESULTS array and counters _collect_result() { local suite="$1" @@ -821,6 +869,7 @@ _collect_result() { echo "FAIL ${suite}/${test_name} (no result file)" RESULTS+=("FAIL ${suite}/${test_name}") FAIL=$((FAIL + 1)) + _print_host_diagnostics "$suite" "$test_name" "$result_dir" return fi @@ -865,6 +914,7 @@ _collect_result() { FAIL=$((FAIL + 1)) ;; esac + _print_host_diagnostics "$suite" "$test_name" "$result_dir" } # ── Run a suite ──────────────────────────────────────────── @@ -910,6 +960,7 @@ run_suite() { if [ ${#specific_tests[@]} -gt 0 ] || [ "$PARALLEL" -le 1 ]; then # Sequential execution for specific tests or when parallelism disabled + _export_xfail_for_suite "$suite" for test_name in "${tests[@]}"; do TOTAL=$((TOTAL + 1)) run_runtime_test "$suite" "$test_name" diff --git a/scripts/test-build-fork-instrument-tool-cwd.sh b/scripts/test-build-fork-instrument-tool-cwd.sh new file mode 100755 index 0000000000..200c1841c9 --- /dev/null +++ b/scripts/test-build-fork-instrument-tool-cwd.sh @@ -0,0 +1,20 @@ +#!/usr/bin/env bash + +# Resolver package builds run their recipes from extracted source trees. Prove +# the shared fork-instrument builder does not depend on inheriting the Kandelo +# repository as its current directory. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +SCRATCH="$(mktemp -d "${TMPDIR:-/tmp}/kandelo-fork-tool-cwd.XXXXXX")" +trap 'rm -rf "$SCRATCH"' EXIT + +( + cd "$SCRATCH" + "$REPO_ROOT/scripts/build-fork-instrument-tool.sh" +) + +test -x "$REPO_ROOT/tools/bin/wasm-fork-instrument" +echo "fork-instrument builder is independent of the caller cwd" diff --git a/scripts/test-dev-shell-tool-precedence.sh b/scripts/test-dev-shell-tool-precedence.sh new file mode 100755 index 0000000000..91651bf9ce --- /dev/null +++ b/scripts/test-dev-shell-tool-precedence.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# Exercise tool resolution through both command shapes used by repository +# builds. The login-shell case is the Darwin regression: /etc/profile used to +# put Homebrew CMake and /usr/bin/make ahead of the flake-declared tools. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CHECK_SCRIPT="$REPO_ROOT/scripts/check-dev-shell-tools.sh" +DEV_SHELL="$REPO_ROOT/scripts/dev-shell.sh" + +bash "$DEV_SHELL" bash "$CHECK_SCRIPT" +bash "$DEV_SHELL" bash -lc 'exec bash "$1"' bash "$CHECK_SCRIPT" diff --git a/scripts/wasm-artifact-guards.sh b/scripts/wasm-artifact-guards.sh index 0a5a4e2681..f4a73b3c13 100644 --- a/scripts/wasm-artifact-guards.sh +++ b/scripts/wasm-artifact-guards.sh @@ -31,17 +31,90 @@ wasm_current_abi_version() { "$repo_root/crates/shared/src/lib.rs" | head -1 } +# Run a producer into awk without inheriting either errexit or pipefail from +# the caller, then return the producer's status before the consumer's. This +# keeps large Wasm inspections streaming while ensuring a decoder failure can +# never be mistaken for a successful negative match. +_wasm_stream_awk() { + local program="${1:-}" + shift || true + [ -n "$program" ] && [ "$#" -gt 0 ] || return 1 + + local restore_errexit=0 + local restore_pipefail=0 + case "$-" in + *e*) restore_errexit=1; set +e ;; + esac + if shopt -qo pipefail; then + restore_pipefail=1 + set +o pipefail + fi + + "$@" 2>/dev/null | awk "$program" + local statuses=("${PIPESTATUS[@]}") + + if [ "$restore_pipefail" -eq 1 ]; then + set -o pipefail + fi + if [ "$restore_errexit" -eq 1 ]; then + set -e + fi + + if [ "${statuses[0]:-1}" -ne 0 ]; then + # Status 1 is also awk's ordinary "predicate did not match" result. + # Map a producer's status 1 to a distinct decoder-error status so + # callers can preserve the predicate's tri-state contract. + if [ "${statuses[0]}" -eq 1 ]; then + return 2 + fi + return "${statuses[0]}" + fi + return "${statuses[1]:-1}" +} + wasm_extract_abi_version() { local path="${1:-}" + local version wasm_is_binary "$path" || return 1 + # Keep the disassembly streaming. Large package binaries (PHP is roughly + # 37 MiB) can produce hundreds of MiB of text and must not be captured in a + # shell variable merely to inspect one function. Prefer Binaryen here: + # WABT 1.0.37 cannot finish disassembling LLVM 21 exception-reference code + # after fork instrumentation, even though the module is valid in V8. + if command -v wasm-dis >/dev/null 2>&1; then + version="$(_wasm_stream_awk ' + index($0, "(export \"__abi_version\" (func $") { + target = $0 + sub(/^.*\(func \$/, "", target) + sub(/\).*$/, "", target) + } + target != "" && index($0, "(func $" target " ") { + in_abi = 1 + next + } + in_abi && match($0, /\(i32.const -?[0-9]+\)/) { + version = substr($0, RSTART + 11, RLENGTH - 12) + in_abi = 0 + } + END { + if (version != "") print version + else exit 1 + } + ' wasm-dis "$path" -o -)" || return $? + printf '%s\n' "$version" + return + fi command -v wasm-objdump >/dev/null 2>&1 || return 1 - local dump - dump="$(wasm-objdump -d "$path" 2>/dev/null)" || return 1 - awk ' + version="$(_wasm_stream_awk ' /<__abi_version>:/ { in_abi = 1; next } - in_abi && /i32.const/ { print $NF; exit } - in_abi && / end$/ { exit } - ' <<< "$dump" + in_abi && version == "" && /i32.const/ { version = $NF; in_abi = 0 } + in_abi && / end$/ { in_abi = 0 } + END { + if (version != "") print version + else exit 1 + } + ' wasm-objdump -d "$path")" || return $? + printf '%s\n' "$version" } wasm_has_stale_abi() { @@ -49,8 +122,14 @@ wasm_has_stale_abi() { local current_abi="${2:-}" [ -n "$current_abi" ] || return 1 - local artifact_abi - artifact_abi="$(wasm_extract_abi_version "$path" || true)" + local artifact_abi extract_status=0 + artifact_abi="$(wasm_extract_abi_version "$path")" || extract_status=$? + # A missing ABI export remains "not stale" for artifacts whose policy does + # not require one. A decoder failure is different: fail closed and let the + # caller reject the artifact rather than accepting uninspected bytes. + if [ "$extract_status" -gt 1 ]; then + return 0 + fi [ -n "$artifact_abi" ] && [ "$artifact_abi" != "$current_abi" ] } @@ -58,10 +137,11 @@ wasm_imports_kernel_fork() { local path="${1:-}" wasm_is_binary "$path" || return 1 if command -v wasm-objdump >/dev/null 2>&1; then - local dump - dump="$(wasm-objdump -x "$path" 2>/dev/null)" || return 1 - grep -q '<- kernel\.kernel_fork' <<< "$dump" - return $? + _wasm_stream_awk ' + /<- kernel\.kernel_fork/ { found = 1 } + END { exit(found ? 0 : 1) } + ' wasm-objdump -x "$path" + return fi # Fallback for environments without wabt/binaryen tools. The field name is # stored as plain UTF-8 in the import section. @@ -74,10 +154,12 @@ wasm_has_wpk_fork_export() { [ -n "$name" ] || return 1 wasm_is_binary "$path" || return 1 if command -v wasm-objdump >/dev/null 2>&1; then - local dump - dump="$(wasm-objdump -x "$path" 2>/dev/null)" || return 1 - grep -q -- "-> \"$name\"" <<< "$dump" - return $? + WASM_ARTIFACT_EXPORT_NAME="$name" \ + _wasm_stream_awk ' + index($0, "-> \"" ENVIRON["WASM_ARTIFACT_EXPORT_NAME"] "\"") { found = 1 } + END { exit(found ? 0 : 1) } + ' wasm-objdump -x "$path" + return fi grep -a -q "$name" "$path" 2>/dev/null } @@ -89,9 +171,11 @@ wasm_has_export() { wasm_has_missing_exports() { local path="${1:-}" shift || true - local name + local name export_status for name in "$@"; do - if ! wasm_has_export "$path" "$name"; then + export_status=0 + wasm_has_export "$path" "$name" || export_status=$? + if [ "$export_status" -ne 0 ]; then return 0 fi done @@ -102,9 +186,11 @@ wasm_require_exports() { local path="${1:-}" shift || true local missing=() - local name + local name export_status for name in "$@"; do - if ! wasm_has_export "$path" "$name"; then + export_status=0 + wasm_has_export "$path" "$name" || export_status=$? + if [ "$export_status" -ne 0 ]; then missing+=("$name") fi done @@ -128,10 +214,11 @@ wasm_is_relocatable_object() { local path="${1:-}" wasm_is_binary "$path" || return 1 if command -v wasm-objdump >/dev/null 2>&1; then - local dump - dump="$(wasm-objdump -x "$path" 2>/dev/null)" || return 1 - grep -q -E 'name: "(linking|reloc\.)' <<< "$dump" - return $? + _wasm_stream_awk ' + /name: "(linking|reloc\.)/ { found = 1 } + END { exit(found ? 0 : 1) } + ' wasm-objdump -x "$path" + return fi case "$path" in *.o) return 0 ;; @@ -141,24 +228,62 @@ wasm_is_relocatable_object() { wasm_has_any_wpk_fork_export() { local path="${1:-}" - wasm_has_wpk_fork_export "$path" wpk_fork_unwind_begin || - wasm_has_wpk_fork_export "$path" wpk_fork_unwind_end || - wasm_has_wpk_fork_export "$path" wpk_fork_rewind_begin || - wasm_has_wpk_fork_export "$path" wpk_fork_rewind_end || - wasm_has_wpk_fork_export "$path" wpk_fork_state + local name export_status + for name in \ + wpk_fork_unwind_begin \ + wpk_fork_unwind_end \ + wpk_fork_rewind_begin \ + wpk_fork_rewind_end \ + wpk_fork_state; do + export_status=0 + wasm_has_wpk_fork_export "$path" "$name" || export_status=$? + case "$export_status" in + 0) return 0 ;; + 1) ;; + *) return 0 ;; # Decoder failure: fail closed as an unsafe artifact. + esac + done + return 1 } wasm_has_missing_fork_instrumentation() { local path="${1:-}" + local predicate_status complete_status wasm_is_binary "$path" || return 1 - wasm_is_relocatable_object "$path" && return 1 - if wasm_imports_kernel_fork "$path" && ! wasm_has_complete_fork_instrumentation "$path"; then - return 0 - fi - if wasm_has_any_wpk_fork_export "$path" && ! wasm_has_complete_fork_instrumentation "$path"; then - return 0 - fi - return 1 + + predicate_status=0 + wasm_is_relocatable_object "$path" || predicate_status=$? + case "$predicate_status" in + 0) return 1 ;; + 1) ;; + *) return 0 ;; # Decoder failure: reject as uninspectable. + esac + + predicate_status=0 + wasm_imports_kernel_fork "$path" || predicate_status=$? + case "$predicate_status" in + 0) + complete_status=0 + wasm_has_complete_fork_instrumentation "$path" || complete_status=$? + [ "$complete_status" -eq 0 ] && return 1 + return 0 + ;; + 1) ;; + *) return 0 ;; + esac + + predicate_status=0 + wasm_has_any_wpk_fork_export "$path" || predicate_status=$? + case "$predicate_status" in + 0) + complete_status=0 + wasm_has_complete_fork_instrumentation "$path" || complete_status=$? + [ "$complete_status" -eq 0 ] && return 1 + return 0 + ;; + 1) return 1 ;; + *) return 0 ;; + esac } wasm_require_fork_instrumentation_if_needed() { diff --git a/sdk/config.site b/sdk/config.site index 759b0e9e60..acfb8fabc0 100644 --- a/sdk/config.site +++ b/sdk/config.site @@ -1,9 +1,11 @@ -# config.site — autoconf cross-compilation cache for wasm32-posix-kernel +# config.site — autoconf cross-compilation cache for Kandelo Wasm targets # -# This file is automatically loaded by wasm32posix-configure via CONFIG_SITE. +# This file is automatically loaded by wasm32posix-configure and +# wasm64posix-configure via CONFIG_SITE. The configure wrappers export +# WASM_POSIX_TARGET_ARCH so pointer-sized cache values match the target. # It presets configure cache variables that cannot be detected via cross- # compilation (link tests fail, run tests impossible). Values reflect our -# musl libc + wasm32 target. +# musl libc + selected Wasm target. # # IMPORTANT: Function availability is based on what musl libc provides # (i.e., what links), NOT on kernel implementation completeness. If @@ -12,25 +14,47 @@ # but they link, and autoconf link-tests are what this file replaces. # # Build scripts can still override individual variables by exporting them -# before calling wasm32posix-configure (exports take precedence over +# before calling a Kandelo configure wrapper (exports take precedence over # config.site defaults set with ${var=default}). -# ─── Type sizes (ILP32 with 64-bit off_t/time_t) ────────────────────────────── +# ─── Type sizes ─────────────────────────────────────────────────────────────── + +# wasm64 uses LP64. Seed only the values that differ from wasm32 before the +# common defaults below; ${var=default} preserves explicit caller overrides. +if [ "${WASM_POSIX_TARGET_ARCH:-wasm32}" = "wasm64" ]; then + ac_cv_sizeof_long=${ac_cv_sizeof_long=8} + ac_cv_sizeof_unsigned_long=${ac_cv_sizeof_unsigned_long=8} + ac_cv_sizeof_size_t=${ac_cv_sizeof_size_t=8} + ac_cv_sizeof_ssize_t=${ac_cv_sizeof_ssize_t=8} + ac_cv_sizeof_ptrdiff_t=${ac_cv_sizeof_ptrdiff_t=8} + ac_cv_sizeof_void_p=${ac_cv_sizeof_void_p=8} + ac_cv_sizeof_uintptr_t=${ac_cv_sizeof_uintptr_t=8} + ac_cv_sizeof_pthread_t=${ac_cv_sizeof_pthread_t=8} + php_cv_sizeof_ssize_t=${php_cv_sizeof_ssize_t=8} + php_cv_sizeof_ptrdiff_t=${php_cv_sizeof_ptrdiff_t=8} + ac_cv_alignof_size_t=${ac_cv_alignof_size_t=8} +fi + +# Common values default to wasm32's ILP32 data model. off_t and time_t remain +# 64-bit on both targets. ac_cv_sizeof_char=${ac_cv_sizeof_char=1} ac_cv_sizeof_short=${ac_cv_sizeof_short=2} ac_cv_sizeof_int=${ac_cv_sizeof_int=4} ac_cv_sizeof_long=${ac_cv_sizeof_long=4} ac_cv_sizeof_long_long=${ac_cv_sizeof_long_long=8} +ac_cv_sizeof_intmax_t=${ac_cv_sizeof_intmax_t=8} ac_cv_sizeof_unsigned_long=${ac_cv_sizeof_unsigned_long=4} ac_cv_sizeof_size_t=${ac_cv_sizeof_size_t=4} +ac_cv_sizeof_ssize_t=${ac_cv_sizeof_ssize_t=4} +ac_cv_sizeof_ptrdiff_t=${ac_cv_sizeof_ptrdiff_t=4} ac_cv_sizeof_void_p=${ac_cv_sizeof_void_p=4} ac_cv_sizeof_off_t=${ac_cv_sizeof_off_t=8} ac_cv_sizeof_time_t=${ac_cv_sizeof_time_t=8} ac_cv_sizeof_pid_t=${ac_cv_sizeof_pid_t=4} ac_cv_sizeof_float=${ac_cv_sizeof_float=4} ac_cv_sizeof_double=${ac_cv_sizeof_double=8} -ac_cv_sizeof_fpos_t=${ac_cv_sizeof_fpos_t=8} +ac_cv_sizeof_fpos_t=${ac_cv_sizeof_fpos_t=16} ac_cv_sizeof_uintptr_t=${ac_cv_sizeof_uintptr_t=4} ac_cv_sizeof_wchar_t=${ac_cv_sizeof_wchar_t=4} ac_cv_sizeof_signed_char=${ac_cv_sizeof_signed_char=1} @@ -39,8 +63,14 @@ ac_cv_sizeof__Bool=${ac_cv_sizeof__Bool=1} ac_cv_sizeof_pthread_t=${ac_cv_sizeof_pthread_t=4} ac_cv_sizeof_pthread_key_t=${ac_cv_sizeof_pthread_key_t=4} +# PHP's PHP_CHECK_SIZEOF macro uses its own cache namespace instead of +# AC_CHECK_SIZEOF's ac_cv_sizeof_* variables for these three types. +php_cv_sizeof_intmax_t=${php_cv_sizeof_intmax_t=8} +php_cv_sizeof_ssize_t=${php_cv_sizeof_ssize_t=4} +php_cv_sizeof_ptrdiff_t=${php_cv_sizeof_ptrdiff_t=4} + ac_cv_alignof_size_t=${ac_cv_alignof_size_t=4} -ac_cv_alignof_max_align_t=${ac_cv_alignof_max_align_t=8} +ac_cv_alignof_max_align_t=${ac_cv_alignof_max_align_t=16} ac_cv_c_bigendian=${ac_cv_c_bigendian=no} diff --git a/sdk/kandelo/bin/wasm32posix-cc b/sdk/kandelo/bin/wasm32posix-cc index 8fab8b8f8c..6727457329 100755 --- a/sdk/kandelo/bin/wasm32posix-cc +++ b/sdk/kandelo/bin/wasm32posix-cc @@ -44,11 +44,11 @@ SYSROOT="${WASM_POSIX_SYSROOT:-/usr/wasm32posix/sysroot}" GLUE_DIR="${WASM_POSIX_GLUE_DIR:-/usr/wasm32posix/glue}" GLUE_OBJ_DIR="${WASM_POSIX_GLUE_OBJ_DIR:-/usr/wasm32posix/glue-objects}" RESOURCE_DIR="${WASM_POSIX_CLANG_RESOURCE_DIR:-/usr/lib/llvm/lib/clang/21}" -WASM_LD="$(find_tool wasm-ld || true)" +WASM_LD="${TOOL_DIR}/wasm-ld" [[ -f "${SYSROOT}/lib/libc.a" ]] || die "missing ${SYSROOT}/lib/libc.a" [[ -f "${GLUE_DIR}/channel_syscall.c" ]] || die "missing ${GLUE_DIR}/channel_syscall.c" -[[ -n "$WASM_LD" ]] || die "wasm-ld not found; set WASM_POSIX_LLVM_DIR" +[[ -x "$WASM_LD" ]] || die "matching wasm-ld not found beside $CLANG; set WASM_POSIX_LLVM_DIR to a complete LLVM toolchain" linking=1 shared=0 @@ -294,6 +294,13 @@ if [[ "$SDK_CXX" -eq 1 ]]; then stdlib_inputs+=("${SYSROOT}/lib/libc++.a" "${SYSROOT}/lib/libc++abi.a") fi +lld_major="$("$WASM_LD" --version | sed -nE 's/.*LLD ([0-9]+).*/\1/p' | head -1)" +[[ "$lld_major" =~ ^[0-9]+$ ]] || die "could not determine wasm-ld version" +if (( lld_major >= 22 )); then + # LLD 22 changed its default to --stack-first. Preserve the established + # stack-after-data layout; LLD 21 does not accept this negative option. + exe_link_flags+=(-Wl,--no-stack-first) +fi append_ld_flags "${exe_link_flags[@]}" "$WASM_LD" -m wasm32 -L"${SYSROOT}/lib" \ "${object_inputs[@]}" "${compiled_objects[@]}" "${glue_inputs[@]}" \ diff --git a/sdk/src/bin/c++.ts b/sdk/src/bin/c++.ts index 01b196ffb5..7870926d25 100755 --- a/sdk/src/bin/c++.ts +++ b/sdk/src/bin/c++.ts @@ -1,6 +1,6 @@ #!/usr/bin/env -S node --experimental-strip-types import { resolveToolchain } from '../lib/toolchain.ts'; -import { buildClangArgs } from './cc.ts'; +import { buildClangArgs, prepareExecutableLinker } from './cc.ts'; import { runPassthrough } from '../lib/exec.ts'; import { isMain } from '../lib/is-main.ts'; import { detectArch } from '../lib/arch.ts'; @@ -8,7 +8,9 @@ import { detectArch } from '../lib/arch.ts'; async function main(): Promise { const arch = detectArch(); const toolchain = await resolveToolchain(arch); - const args = buildClangArgs(process.argv.slice(2), toolchain, arch); + const userArgs = process.argv.slice(2); + await prepareExecutableLinker(userArgs, toolchain, arch); + const args = buildClangArgs(userArgs, toolchain, arch); const exitCode = await runPassthrough(toolchain.cxx, args); process.exit(exitCode); } diff --git a/sdk/src/bin/cc.ts b/sdk/src/bin/cc.ts index 33bd8d2c23..247df83eb7 100755 --- a/sdk/src/bin/cc.ts +++ b/sdk/src/bin/cc.ts @@ -1,7 +1,7 @@ #!/usr/bin/env -S node --experimental-strip-types import { readFileSync } from 'node:fs'; import { join } from 'node:path'; -import { resolveToolchain, type Toolchain } from '../lib/toolchain.ts'; +import { resolveLldMajor, resolveToolchain, type Toolchain } from '../lib/toolchain.ts'; import { compileFlags, filterArgs, @@ -57,10 +57,19 @@ export function buildClangArgs(userArgs: string[], toolchain: Toolchain, arch: W if (parsed.pic) args.push('-fPIC'); if (linking) { + // Keep clang and lld in the same resolved LLVM tree. Without an explicit + // linker path, clang can pick an unrelated ambient wasm-ld whose defaults + // differ from the repository-pinned toolchain. + args.push(`-fuse-ld=${join(toolchain.llvmDir, 'wasm-ld')}`); if (parsed.shared) { // Shared library build: no CRT, no libc, no syscall glue args.push(...SHARED_LINK_FLAGS); } else { + if (toolchain.lldMajor === null) { + throw new Error( + 'wasm-ld version is unresolved; call prepareExecutableLinker() before building executable link arguments', + ); + } // Executable build: link CRT, libc, and syscall glue const threadSlots = inferThreadSlotDeclaration(parsed, userArgs, { readFile: (path) => { @@ -85,6 +94,10 @@ export function buildClangArgs(userArgs: string[], toolchain: Toolchain, arch: W args.push( join(toolchain.sysroot, 'lib', 'crt1.o'), join(toolchain.sysroot, 'lib', 'libc.a'), + // LLD 22 made --stack-first the default; LLD 21 neither defaults to + // it nor accepts --no-stack-first. Preserve Kandelo's established + // stack-after-data layout explicitly only where the option exists. + ...(toolchain.lldMajor >= 22 ? ['-Wl,--no-stack-first'] : []), ...linkFlags(arch), ); } @@ -93,10 +106,24 @@ export function buildClangArgs(userArgs: string[], toolchain: Toolchain, arch: W return args; } +export async function prepareExecutableLinker( + userArgs: string[], + toolchain: Toolchain, + arch: WasmArch = 'wasm32', +): Promise { + const { filtered } = filterArgs(userArgs, arch); + const parsed = parseArgs(filtered); + if (needsLinking(parsed) && !parsed.shared) { + toolchain.lldMajor = await resolveLldMajor(toolchain.llvmDir); + } +} + async function main(): Promise { const arch = detectArch(); const toolchain = await resolveToolchain(arch); - const args = buildClangArgs(process.argv.slice(2), toolchain, arch); + const userArgs = process.argv.slice(2); + await prepareExecutableLinker(userArgs, toolchain, arch); + const args = buildClangArgs(userArgs, toolchain, arch); const exitCode = await runPassthrough(toolchain.cc, args); process.exit(exitCode); } diff --git a/sdk/src/bin/configure.ts b/sdk/src/bin/configure.ts index 4b3717bcae..8c7b4dc4ec 100755 --- a/sdk/src/bin/configure.ts +++ b/sdk/src/bin/configure.ts @@ -31,6 +31,7 @@ export function buildConfigureEnv(arch: WasmArch = 'wasm32'): Record { + const result = await run(join(llvmDir, 'wasm-ld'), ['--version']); + const match = `${result.stdout}\n${result.stderr}`.match(/\bLLD\s+(\d+)/i); + if (result.exitCode !== 0 || !match) { + throw new Error(`Could not determine wasm-ld version in ${llvmDir}`); + } + return Number(match[1]); +} + export async function resolveToolchain(arch: WasmArch = 'wasm32'): Promise { const llvmDir = await findLlvmDir(); const sysroot = findSysroot(arch); @@ -144,6 +154,7 @@ export async function resolveToolchain(arch: WasmArch = 'wasm32'): Promise { const toolchain = { llvmDir: '/opt/llvm/bin', + lldMajor: 21, cc: '/opt/llvm/bin/clang', cxx: '/opt/llvm/bin/clang++', ar: '/opt/llvm/bin/llvm-ar', @@ -72,4 +75,47 @@ describe('buildClangArgs', () => { expect(args).toContain('-DWASM_POSIX_THREAD_SLOT_DECL=2'); expect(args).not.toContain('--kandelo-thread-slots=2'); }); + + it('pins lld to the same resolved LLVM tree as clang', () => { + const args = buildClangArgs(['foo.c', '-o', 'foo.wasm'], toolchain); + + expect(args).toContain('-fuse-ld=/opt/llvm/bin/wasm-ld'); + }); + + it('rejects executable link arguments before wasm-ld is versioned', () => { + expect(() => + buildClangArgs( + ['foo.c', '-o', 'foo.wasm'], + { ...toolchain, lldMajor: null }, + ), + ).toThrow(/wasm-ld version is unresolved/); + }); + + it('pins the packaged SDK driver to clang\'s adjacent wasm-ld', () => { + const script = readFileSync( + join(import.meta.dirname, '../kandelo/bin/wasm32posix-cc'), + 'utf8', + ); + + expect(script).toContain('WASM_LD="${TOOL_DIR}/wasm-ld"'); + expect(script).not.toContain('WASM_LD="$(find_tool wasm-ld'); + }); + + it('preserves stack-after-data layout with LLD 22 and newer', () => { + const args = buildClangArgs( + ['foo.c', '-o', 'foo.wasm'], + { ...toolchain, lldMajor: 22 }, + ); + + expect(args).toContain('-Wl,--no-stack-first'); + }); + + it('uses LLD 21 defaults without passing its unsupported negative option', () => { + const args = buildClangArgs( + ['foo.c', '-o', 'foo.wasm'], + { ...toolchain, lldMajor: 21 }, + ); + + expect(args).not.toContain('-Wl,--no-stack-first'); + }); }); diff --git a/sdk/test/configure.test.ts b/sdk/test/configure.test.ts index 6be2bde8cf..e44f8cfa47 100644 --- a/sdk/test/configure.test.ts +++ b/sdk/test/configure.test.ts @@ -21,5 +21,12 @@ describe('buildConfigureEnv', () => { expect(env.CC).toBe('wasm32posix-cc'); expect(env.AR).toBe('wasm32posix-ar'); expect(env.STRIP).toBe('wasm32posix-strip'); + expect(env.WASM_POSIX_TARGET_ARCH).toBe('wasm32'); + }); + + it('identifies wasm64 so config.site selects LP64 cache values', () => { + const env = buildConfigureEnv('wasm64'); + expect(env.CC).toBe('wasm64posix-cc'); + expect(env.WASM_POSIX_TARGET_ARCH).toBe('wasm64'); }); }); diff --git a/sdk/test/integration.test.ts b/sdk/test/integration.test.ts index e93d1a1df6..416b446c3d 100644 --- a/sdk/test/integration.test.ts +++ b/sdk/test/integration.test.ts @@ -4,7 +4,7 @@ import { join, resolve, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { execFileSync } from 'node:child_process'; import { resolveToolchain } from '../src/lib/toolchain.ts'; -import { buildClangArgs } from '../src/bin/cc.ts'; +import { buildClangArgs, prepareExecutableLinker } from '../src/bin/cc.ts'; import { run } from '../src/lib/exec.ts'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -43,6 +43,39 @@ beforeAll(() => { }); describe('integration: compile C program', () => { + it('pins the complete wasm32 pointer-sized autoconf types', () => { + const site = readFileSync(join(SDK_ROOT, 'config.site'), 'utf8'); + + expect(site).toContain('ac_cv_sizeof_intmax_t=${ac_cv_sizeof_intmax_t=8}'); + expect(site).toContain('ac_cv_sizeof_ssize_t=${ac_cv_sizeof_ssize_t=4}'); + expect(site).toContain('ac_cv_sizeof_ptrdiff_t=${ac_cv_sizeof_ptrdiff_t=4}'); + expect(site).toContain('php_cv_sizeof_intmax_t=${php_cv_sizeof_intmax_t=8}'); + expect(site).toContain('php_cv_sizeof_ssize_t=${php_cv_sizeof_ssize_t=4}'); + expect(site).toContain('php_cv_sizeof_ptrdiff_t=${php_cv_sizeof_ptrdiff_t=4}'); + expect(site).toContain('ac_cv_sizeof_fpos_t=${ac_cv_sizeof_fpos_t=16}'); + expect(site).toContain('ac_cv_alignof_max_align_t=${ac_cv_alignof_max_align_t=16}'); + expect(site).toContain('php_cv_sizeof_ssize_t=${php_cv_sizeof_ssize_t=8}'); + expect(site).toContain('php_cv_sizeof_ptrdiff_t=${php_cv_sizeof_ptrdiff_t=8}'); + }); + + it('pins clang to the resolved wasm-ld', async () => { + const toolchain = await resolveToolchain(); + mkdirSync(TMP_DIR, { recursive: true }); + const srcFile = join(TMP_DIR, 'linker-probe.c'); + const outFile = join(TMP_DIR, 'linker-probe.wasm'); + writeFileSync(srcFile, 'int main(void) { return 0; }\n'); + + const userArgs = ['-###', srcFile, '-o', outFile]; + await prepareExecutableLinker(userArgs, toolchain); + const args = buildClangArgs(userArgs, toolchain); + const result = await run(toolchain.cc, args); + + expect(result.exitCode).toBe(0); + expect(result.stderr).toContain(join(toolchain.llvmDir, 'wasm-ld')); + try { unlinkSync(srcFile); } catch {} + try { unlinkSync(outFile); } catch {} + }, 30_000); + it('compiles a hello world program to .wasm', async () => { const toolchain = await resolveToolchain(); mkdirSync(TMP_DIR, { recursive: true }); @@ -58,7 +91,9 @@ describe('integration: compile C program', () => { } `); - const args = buildClangArgs([srcFile, '-o', outFile], toolchain); + const userArgs = [srcFile, '-o', outFile]; + await prepareExecutableLinker(userArgs, toolchain); + const args = buildClangArgs(userArgs, toolchain); const result = await run(toolchain.cc, args); if (result.exitCode !== 0) { @@ -72,6 +107,47 @@ describe('integration: compile C program', () => { try { unlinkSync(outFile); } catch {} }, 30_000); + it('links timer_create without fictional raw setjmp imports', async () => { + const toolchain = await resolveToolchain(); + mkdirSync(TMP_DIR, { recursive: true }); + + const srcFile = join(TMP_DIR, 'timer-create.c'); + const outFile = join(TMP_DIR, 'timer-create.wasm'); + writeFileSync(srcFile, ` + #include + #include + + int main(void) { + struct sigevent event = {0}; + timer_t timer; + event.sigev_notify = SIGEV_SIGNAL; + event.sigev_signo = SIGALRM; + return timer_create(CLOCK_MONOTONIC, &event, &timer); + } + `); + + try { + const userArgs = [srcFile, '-o', outFile]; + await prepareExecutableLinker(userArgs, toolchain); + const args = buildClangArgs(userArgs, toolchain); + const result = await run(toolchain.cc, args); + if (result.exitCode !== 0) { + console.error('clang stderr:', result.stderr); + } + expect(result.exitCode).toBe(0); + + const module = new WebAssembly.Module(readFileSync(outFile)); + const envImports = WebAssembly.Module.imports(module) + .filter((entry) => entry.module === 'env') + .map((entry) => entry.name); + expect(envImports).not.toContain('setjmp'); + expect(envImports).not.toContain('longjmp'); + } finally { + try { unlinkSync(srcFile); } catch {} + try { unlinkSync(outFile); } catch {} + } + }, 30_000); + it('compiles in compile-only mode', async () => { const toolchain = await resolveToolchain(); mkdirSync(TMP_DIR, { recursive: true }); diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_ca.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_ca.pem new file mode 100644 index 0000000000..7d12f30a49 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_ca.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmzCCAoOgAwIBAgIUbFxrb9gyAFWFxJbfO2zE80o58NUwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBVMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDEQMA4GA1UECwwHb3BlbnNzbDEQ +MA4GA1UEAwwHcGhwLm5ldDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEB +AOPtB+W9PFt25KBpeOZxECDFqfoyJSUdQP1Id5NKWm9E1Xv3C/BSKuH0CKUJOv15 +Ar88GK8LitUA5Lux4eJ6KHu9LNe6Q1bn62bEdYaK/r/yIkDEBw4fq3IKfZwr0aL8 +FD0Celxb8+tgNcms1NgwaKDyr4ZV63eagQyzglFC+a0B1ebI5SEI1+1TshZm4wK0 +9nkCxg5k9g2HykxweL2txntj6cq1DCRWcfSQGoYuyxv0ZUqlKpJ9Db+KootXWbVw +GQNEoqmjLr/BhNcIcDaeWl88rYqdF5Fyjis4TgvWeFmaqwzjhrv5Xdi/hAK69WKR +SCwSANR4phUgT3YUECiaZhkCAwEAAaNjMGEwDwYDVR0TAQH/BAUwAwEB/zAOBgNV +HQ8BAf8EBAMCAQYwHQYDVR0OBBYEFIoUhHItIO0KRg/VqCuif9w6CinhMB8GA1Ud +IwQYMBaAFIoUhHItIO0KRg/VqCuif9w6CinhMA0GCSqGSIb3DQEBCwUAA4IBAQCG +SgcPVF6eUUtPNZywAZvHRBFkBp/8iklG8cTzw/krrOEjsCu5gkvePaMWpS8jYf+D +ogTvUXuO574Do0vDu6QRg2bq/NzAObjjW7NDpKYJYPDJpZy8EVkhygCVoJlca8iN +fTHCyipIpcwulLlTzNVLcHWBeImrrbWbWBLanCX3PMXdcw/gleLmDqOPQIUx/fT/ +7zNAc226ElMVRsrLaQaox9eT7LiWCE89tQPYuhCE+ot7USYAk9Mejzqj2QIMCgql +v9ZRbAHjzEC0XtU2lxrskaJfmw+wrbu38YbCBrGgoYu3GsLfGmV5TCGF6Ywo2m1k +vgBaIImPacDxAnw958yv +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_cs.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_cs.pem new file mode 100644 index 0000000000..17dc7162d0 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_cs.pem @@ -0,0 +1,50 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCngdE7dj025OjC +S+49EVxUVRSf/B12p4307zCVQvrWxTMI8gjyEuVaENvGkEz1E1bcCMQuE6vq3EAd +4XcUuSaKj8KX8xyB8MVqF9ZncKrXFx2SNkDgi4asj0DH0ESiZRncwY2OMFUuBdU8 +c6rJyiqmFA2dHJRGEDdQb4+0xYqezJv0TxpK0dho155YrIMR5hdBLzII0iYPN7eM +QSI6u98FkZRNyO/2JbO0ohXnyFf9g0DqaJoS6SklBWoy+afT2JHuG2nrhxLvglwB +VP4OG8zTxa6lzN46pYHShr8mU9hGl5DpwxFaUT4xN8MpB8j68GE0EoFpfDgdVpVX +6YfRYXp5AgMBAAECggEAIOs+wthMVG7tHU4NzluMkRg9WumRosFwAPxGyHam1DFi +A7aJMoWrJCWfA2lVbm50Cf8BCusbxKavRRALtntiiyWQgQsqhZDbQyvXG3IFaQrm +aVcXLenNuDjY6svPyThkOkGhf5mZC9finaQ/orOD6SqUuFUnAJPsQvcBv214/hb2 +2xcNf4r5mZPI3CpLtk8x70YoFfPPd4szhZ9cV6qCQuRcDkYBQMHkppytcvmleMdb +0MFKQKEPNE+iJDRBuwYDhDbbZAfjtt6Av4VPtAgusMPu4dZp/sD3TCBm4MgdFeKC +pTDvXXwiBd/7/1Y3nwWka4qRm8xYkSy7g4cs+R27OQKBgQDrPEXJl29HjYnJJZpm +kq1PowVw4ss+DDr06KQn2tLYYD4LrSo5Y8TrpZI2HZ4n359DPrE/g5hKThEyIbMb +pRkCGaseOlMjLmCcy7NQ+SBim/MNmdd+3ZKkOXSKOLFyJVyiGoS3oN63u3zOqS+D +AEeHOv+MKijfkwqua7Wn1BTypQKBgQC2Sw4ajhtqNzITkrauf4B9T1nMhAkBP3LO +Sk9pFiQzK9aj9Hzkh/W34RS+IruYjLSpuEvFk6cZJmx0BLSnr6moNr2aeaHmDzjz +UL3yL5bQtmdzqcW7VDq6HdAMLXzqFxnoSTluD0jGPlqlUyDVYjiZjKXQGcokBqXt +2ro16POERQKBgQC6M4d6PyiSlvLOCbniH5CYTQ5tgNgoUT4JxmzKL2heZjA2xed6 +bgLeFL1boK4kGdrTO7jJ8byGdK0f5ZUE+PaGtxLAZqKQYpGPC57xJYIBDDikN3Zg +sbr1y5T4JFAxbmmY1yzevbQN85ajb0BwjbQQ0x6dMHISJy55SkEMi8/ZEQKBgC2K +Cnfv0EF0kHn7SM/G4l5rmE9THLzHEPMf9T6XXWu7I9/Jj/m3TVcmT3xASQUPAYL0 +m1k8TZ4mzykck9TgOU3gfvU8NYm3e86s+QarhM2XA7kNTGxD2nfsQrEIHBCOvNYe +lM492zxLD3IFlko4Q1N4o2OdTbY6QvFsluAo1czRAoGARaokauDWHRN1vJRwT/Tv +oCnKdGDT62Fe7zyC2uBfMEd5ikpO6+g8lw5TPcvThrPbG+mFeryHXCFZos9FZJag +gvBSU+biH8GAZUiZmTPFdLvJTmBaujhjrWDJW6wo8UYHvxOzeIeUrz/56/nbhiuC +LPXVIJrV8dz3rDoc60SsuO4= +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUgwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKY3MucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKeB0Tt2PTbk6MJL7j0R +XFRVFJ/8HXanjfTvMJVC+tbFMwjyCPIS5VoQ28aQTPUTVtwIxC4Tq+rcQB3hdxS5 +JoqPwpfzHIHwxWoX1mdwqtcXHZI2QOCLhqyPQMfQRKJlGdzBjY4wVS4F1TxzqsnK +KqYUDZ0clEYQN1Bvj7TFip7Mm/RPGkrR2GjXnlisgxHmF0EvMgjSJg83t4xBIjq7 +3wWRlE3I7/Yls7SiFefIV/2DQOpomhLpKSUFajL5p9PYke4baeuHEu+CXAFU/g4b +zNPFrqXM3jqlgdKGvyZT2EaXkOnDEVpRPjE3wykHyPrwYTQSgWl8OB1WlVfph9Fh +enkCAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggpjcy5waHAubmV0MB0GA1UdDgQWBBTw +ZzGQ1lMAdYvJIyf6WOjBYOiqzzAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAKYbB8i+zeJjjN+9UdXIHhgosHeaTUpvf +KdoMkHGVVIch2a0wnizKB5ovXvH3FIceTui2/gr6RFryuRCL942wiAi70tH3IWKw +g8H9yATVrWPD/11Myzm3328FqwC8YOsuqx9Q5cVdbPF1ucIeJ2w73htAvrtZdUWI +nY3z7D/4M77MQ8JwqzIaWD7EXXnU3ed1XyhIZ6xwXsB2ovRD5TG/Dvl4w8egebOz +1/9F9uIEEnZZ84tuL4zoawXGhWsDgGzsf+yxmwjitaVzUG8yhUNILl1mwLmcZl1F +/6Fx49f8nUUR8Fx1j476Xti+Yo3K9AedClwvs8WVCmVD/FD66FK/ZA== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_cs_cert.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_cert.pem new file mode 100644 index 0000000000..3b27490e50 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUgwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKY3MucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKeB0Tt2PTbk6MJL7j0R +XFRVFJ/8HXanjfTvMJVC+tbFMwjyCPIS5VoQ28aQTPUTVtwIxC4Tq+rcQB3hdxS5 +JoqPwpfzHIHwxWoX1mdwqtcXHZI2QOCLhqyPQMfQRKJlGdzBjY4wVS4F1TxzqsnK +KqYUDZ0clEYQN1Bvj7TFip7Mm/RPGkrR2GjXnlisgxHmF0EvMgjSJg83t4xBIjq7 +3wWRlE3I7/Yls7SiFefIV/2DQOpomhLpKSUFajL5p9PYke4baeuHEu+CXAFU/g4b +zNPFrqXM3jqlgdKGvyZT2EaXkOnDEVpRPjE3wykHyPrwYTQSgWl8OB1WlVfph9Fh +enkCAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggpjcy5waHAubmV0MB0GA1UdDgQWBBTw +ZzGQ1lMAdYvJIyf6WOjBYOiqzzAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAKYbB8i+zeJjjN+9UdXIHhgosHeaTUpvf +KdoMkHGVVIch2a0wnizKB5ovXvH3FIceTui2/gr6RFryuRCL942wiAi70tH3IWKw +g8H9yATVrWPD/11Myzm3328FqwC8YOsuqx9Q5cVdbPF1ucIeJ2w73htAvrtZdUWI +nY3z7D/4M77MQ8JwqzIaWD7EXXnU3ed1XyhIZ6xwXsB2ovRD5TG/Dvl4w8egebOz +1/9F9uIEEnZZ84tuL4zoawXGhWsDgGzsf+yxmwjitaVzUG8yhUNILl1mwLmcZl1F +/6Fx49f8nUUR8Fx1j476Xti+Yo3K9AedClwvs8WVCmVD/FD66FK/ZA== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_cs_key.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_key.pem new file mode 100644 index 0000000000..e0a8efcbbc --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_cs_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCngdE7dj025OjC +S+49EVxUVRSf/B12p4307zCVQvrWxTMI8gjyEuVaENvGkEz1E1bcCMQuE6vq3EAd +4XcUuSaKj8KX8xyB8MVqF9ZncKrXFx2SNkDgi4asj0DH0ESiZRncwY2OMFUuBdU8 +c6rJyiqmFA2dHJRGEDdQb4+0xYqezJv0TxpK0dho155YrIMR5hdBLzII0iYPN7eM +QSI6u98FkZRNyO/2JbO0ohXnyFf9g0DqaJoS6SklBWoy+afT2JHuG2nrhxLvglwB +VP4OG8zTxa6lzN46pYHShr8mU9hGl5DpwxFaUT4xN8MpB8j68GE0EoFpfDgdVpVX +6YfRYXp5AgMBAAECggEAIOs+wthMVG7tHU4NzluMkRg9WumRosFwAPxGyHam1DFi +A7aJMoWrJCWfA2lVbm50Cf8BCusbxKavRRALtntiiyWQgQsqhZDbQyvXG3IFaQrm +aVcXLenNuDjY6svPyThkOkGhf5mZC9finaQ/orOD6SqUuFUnAJPsQvcBv214/hb2 +2xcNf4r5mZPI3CpLtk8x70YoFfPPd4szhZ9cV6qCQuRcDkYBQMHkppytcvmleMdb +0MFKQKEPNE+iJDRBuwYDhDbbZAfjtt6Av4VPtAgusMPu4dZp/sD3TCBm4MgdFeKC +pTDvXXwiBd/7/1Y3nwWka4qRm8xYkSy7g4cs+R27OQKBgQDrPEXJl29HjYnJJZpm +kq1PowVw4ss+DDr06KQn2tLYYD4LrSo5Y8TrpZI2HZ4n359DPrE/g5hKThEyIbMb +pRkCGaseOlMjLmCcy7NQ+SBim/MNmdd+3ZKkOXSKOLFyJVyiGoS3oN63u3zOqS+D +AEeHOv+MKijfkwqua7Wn1BTypQKBgQC2Sw4ajhtqNzITkrauf4B9T1nMhAkBP3LO +Sk9pFiQzK9aj9Hzkh/W34RS+IruYjLSpuEvFk6cZJmx0BLSnr6moNr2aeaHmDzjz +UL3yL5bQtmdzqcW7VDq6HdAMLXzqFxnoSTluD0jGPlqlUyDVYjiZjKXQGcokBqXt +2ro16POERQKBgQC6M4d6PyiSlvLOCbniH5CYTQ5tgNgoUT4JxmzKL2heZjA2xed6 +bgLeFL1boK4kGdrTO7jJ8byGdK0f5ZUE+PaGtxLAZqKQYpGPC57xJYIBDDikN3Zg +sbr1y5T4JFAxbmmY1yzevbQN85ajb0BwjbQQ0x6dMHISJy55SkEMi8/ZEQKBgC2K +Cnfv0EF0kHn7SM/G4l5rmE9THLzHEPMf9T6XXWu7I9/Jj/m3TVcmT3xASQUPAYL0 +m1k8TZ4mzykck9TgOU3gfvU8NYm3e86s+QarhM2XA7kNTGxD2nfsQrEIHBCOvNYe +lM492zxLD3IFlko4Q1N4o2OdTbY6QvFsluAo1czRAoGARaokauDWHRN1vJRwT/Tv +oCnKdGDT62Fe7zyC2uBfMEd5ikpO6+g8lw5TPcvThrPbG+mFeryHXCFZos9FZJag +gvBSU+biH8GAZUiZmTPFdLvJTmBaujhjrWDJW6wo8UYHvxOzeIeUrz/56/nbhiuC +LPXVIJrV8dz3rDoc60SsuO4= +-----END PRIVATE KEY----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_uk.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_uk.pem new file mode 100644 index 0000000000..1471e11da2 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_uk.pem @@ -0,0 +1,50 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCdOoxfvHn15Ozy +pykYYG/DuTMU4Qe1Ku+XdtnsGpygAhOkAdA6Wa/D/ZeUzq58SjkjV93HU4HHZFCF +eMCtlimszwiuSCYbYQobUuqf0+D/N/apNlR+EdIkg/jDPDv+/PnV+1DOEOL2t+zk +QO4i9TV+6bdqgvMTAUTwLH8aUW8b+gQI9O+/etTQt3kZwsC1H7hpIfZdo3dgr5cb +08+XooXRJBQ/PoA4ybCEF+oW+Ws0qhNcyYPhLxB8ijUnEiU9zNOSC0mwxZX7AlZN +kH45S2a5CQ9A7EbqbRp1AnwgToy8a5hqLOeIX55TWQY+4qnqW0xmhROZp9HPyX0F +6hVAnVBdAgMBAAECgf8ld6ZhxDGZIRQ3vCpXz28J265UsqCul+jxVFpaxBg5ioG1 +kjpug/ZajfRFnq3+Iejvbovlot5T+Zqh/M3dE/rMqHOAhXylQ25Hiv2P5zxMzzav +fIOgeYisMHm4akX9Vb8rU4KaEaXo1Wo5xiwMtPRAQHy4UclhPgZXfR9Acf4tbjx4 +9dHFwPCdrEfbssQ9S9k/G1/tY/qAZKUdk3GbQCWpgTukE/4ce1OyxRhEg+cOcTnz +K/VcNEB1Ud9AXj++CHjrv88pYeGPyR/Bvvt+7bb3sJH3J3ub+55BPd/2NrDcAwfa +ovNbupOAmhkSg7s+vbuhaYn9dVYETJMV6VFR4mMCgYEA3VD/G5UImDlr2SODU4/X +jApYv4tGPzeoaBrSC07BRySgnD6CT5xqkSXy8FqRDSnujOeUnr2JSLY+7it3978M +AzZ/grjqnOHQJIvjKqVpUydMEqDZXozR5nfde0liQdn7ieHJy7JtlICfY7VWKHdH +u4BfQuCq21NGh0VD4ExKwI8CgYEAtd5qJY9CDrrxI0WMMmjng2lfhqJkSuj/NsBG +9fFz17u2vSwrFLhup9nS3T3lvyjktUu3RsMr3VDVizOg4GRHTUSefBnc9aIkJi9f +Xe7/RE7z1RQzi3tCh/qHY51+rqie27ldaIUORDBIYufmlKRrcXf1oq3mLyDu7/Ea +AzT9/lMCgYEAiFFugN/ARnr/6eRM7LhpzNFGrtyrbR7sNQtoGxzsQdTWtMZv33d5 +0GOuistOEuykCdhOm6QlHkta4bqWj1v2mzgDPFKH+A/C8+/SAZ+XC2fmHIdEvE9C +rpFgM6MUyXjpzZjsgfIqOtEq2/CC3DP0VBKTGo9lzegyRfmtAEgdtxMCgYBSPR/c +mhox1QDjhThvNxaxSr3igJ4/bXqyhGHSJvvOKtjoVAerF/cZuZrrZmj0dwZsoK9g +tTKpojrd9luh/FZtr0DHN16+SDJ2fedu73rIpbcGvFrOkM818+iy9+3oBuHG09xU +Pg6EcRzadKjEwFDBf5A4ntA+sXK6V1j+6ECOdwKBgDJ/gjilnFni0VECZKulaWNo +3uYD8F9vZooSFUr7+amu+Diue/PUPA4NDFhXvrfzxIuBvUWuNJXTXuyw4XpWKC1H +WwXzuj9P0Ssjg5YYP8htl4YH1rn7lFJ16lraerynZdZS+1vyggUChqkrezzrnX5u +BkYpQzVIQ+JKuHbWG/iX +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUkwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdWsucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ06jF+8efXk7PKnKRhg +b8O5MxThB7Uq75d22ewanKACE6QB0DpZr8P9l5TOrnxKOSNX3cdTgcdkUIV4wK2W +KazPCK5IJhthChtS6p/T4P839qk2VH4R0iSD+MM8O/78+dX7UM4Q4va37ORA7iL1 +NX7pt2qC8xMBRPAsfxpRbxv6BAj077961NC3eRnCwLUfuGkh9l2jd2CvlxvTz5ei +hdEkFD8+gDjJsIQX6hb5azSqE1zJg+EvEHyKNScSJT3M05ILSbDFlfsCVk2QfjlL +ZrkJD0DsRuptGnUCfCBOjLxrmGos54hfnlNZBj7iqepbTGaFE5mn0c/JfQXqFUCd +UF0CAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1ay5waHAubmV0MB0GA1UdDgQWBBS/ +TlI4W/dtnINRF6NZMr690pcz1jAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAqKLfMtAUd4tNlHT6ujOZdRVGARR1r9tz +0y48X2rwV/vQ5mg98Sb4Tq515pY0t9+TAwMmoEAbZcv29BiQfWXYBLeqvmGTNBEQ ++tU2OAQ284rIui9KV5tBH65x79sbg93KRbgdBUXzqQN1TvINDLtnS2IWmzCuf7YT +qe/1SV1bKSXTsqMfzS7MswpXjhzSn4M4CCiNypBGH05bTFOtK6fZtuY8eAuXr+k7 +pi0eUOureItWM/8fh6KEUbIB9AnFWdT7vZSAuNnznfdUVxQ3LLjbvUdgJJ3RCYTs +ttPJGQDmXsDt6IWsWUwBsWP6H6iA0apRUfC165vV2UKa36CsOk3Cfw== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_uk_cert.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_cert.pem new file mode 100644 index 0000000000..6562c60eb3 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUkwDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUyWhcNMzYwNjA5MTYxOTUyWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdWsucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJ06jF+8efXk7PKnKRhg +b8O5MxThB7Uq75d22ewanKACE6QB0DpZr8P9l5TOrnxKOSNX3cdTgcdkUIV4wK2W +KazPCK5IJhthChtS6p/T4P839qk2VH4R0iSD+MM8O/78+dX7UM4Q4va37ORA7iL1 +NX7pt2qC8xMBRPAsfxpRbxv6BAj077961NC3eRnCwLUfuGkh9l2jd2CvlxvTz5ei +hdEkFD8+gDjJsIQX6hb5azSqE1zJg+EvEHyKNScSJT3M05ILSbDFlfsCVk2QfjlL +ZrkJD0DsRuptGnUCfCBOjLxrmGos54hfnlNZBj7iqepbTGaFE5mn0c/JfQXqFUCd +UF0CAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1ay5waHAubmV0MB0GA1UdDgQWBBS/ +TlI4W/dtnINRF6NZMr690pcz1jAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAqKLfMtAUd4tNlHT6ujOZdRVGARR1r9tz +0y48X2rwV/vQ5mg98Sb4Tq515pY0t9+TAwMmoEAbZcv29BiQfWXYBLeqvmGTNBEQ ++tU2OAQ284rIui9KV5tBH65x79sbg93KRbgdBUXzqQN1TvINDLtnS2IWmzCuf7YT +qe/1SV1bKSXTsqMfzS7MswpXjhzSn4M4CCiNypBGH05bTFOtK6fZtuY8eAuXr+k7 +pi0eUOureItWM/8fh6KEUbIB9AnFWdT7vZSAuNnznfdUVxQ3LLjbvUdgJJ3RCYTs +ttPJGQDmXsDt6IWsWUwBsWP6H6iA0apRUfC165vV2UKa36CsOk3Cfw== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_uk_key.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_key.pem new file mode 100644 index 0000000000..b6932ec1d6 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_uk_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQCdOoxfvHn15Ozy +pykYYG/DuTMU4Qe1Ku+XdtnsGpygAhOkAdA6Wa/D/ZeUzq58SjkjV93HU4HHZFCF +eMCtlimszwiuSCYbYQobUuqf0+D/N/apNlR+EdIkg/jDPDv+/PnV+1DOEOL2t+zk +QO4i9TV+6bdqgvMTAUTwLH8aUW8b+gQI9O+/etTQt3kZwsC1H7hpIfZdo3dgr5cb +08+XooXRJBQ/PoA4ybCEF+oW+Ws0qhNcyYPhLxB8ijUnEiU9zNOSC0mwxZX7AlZN +kH45S2a5CQ9A7EbqbRp1AnwgToy8a5hqLOeIX55TWQY+4qnqW0xmhROZp9HPyX0F +6hVAnVBdAgMBAAECgf8ld6ZhxDGZIRQ3vCpXz28J265UsqCul+jxVFpaxBg5ioG1 +kjpug/ZajfRFnq3+Iejvbovlot5T+Zqh/M3dE/rMqHOAhXylQ25Hiv2P5zxMzzav +fIOgeYisMHm4akX9Vb8rU4KaEaXo1Wo5xiwMtPRAQHy4UclhPgZXfR9Acf4tbjx4 +9dHFwPCdrEfbssQ9S9k/G1/tY/qAZKUdk3GbQCWpgTukE/4ce1OyxRhEg+cOcTnz +K/VcNEB1Ud9AXj++CHjrv88pYeGPyR/Bvvt+7bb3sJH3J3ub+55BPd/2NrDcAwfa +ovNbupOAmhkSg7s+vbuhaYn9dVYETJMV6VFR4mMCgYEA3VD/G5UImDlr2SODU4/X +jApYv4tGPzeoaBrSC07BRySgnD6CT5xqkSXy8FqRDSnujOeUnr2JSLY+7it3978M +AzZ/grjqnOHQJIvjKqVpUydMEqDZXozR5nfde0liQdn7ieHJy7JtlICfY7VWKHdH +u4BfQuCq21NGh0VD4ExKwI8CgYEAtd5qJY9CDrrxI0WMMmjng2lfhqJkSuj/NsBG +9fFz17u2vSwrFLhup9nS3T3lvyjktUu3RsMr3VDVizOg4GRHTUSefBnc9aIkJi9f +Xe7/RE7z1RQzi3tCh/qHY51+rqie27ldaIUORDBIYufmlKRrcXf1oq3mLyDu7/Ea +AzT9/lMCgYEAiFFugN/ARnr/6eRM7LhpzNFGrtyrbR7sNQtoGxzsQdTWtMZv33d5 +0GOuistOEuykCdhOm6QlHkta4bqWj1v2mzgDPFKH+A/C8+/SAZ+XC2fmHIdEvE9C +rpFgM6MUyXjpzZjsgfIqOtEq2/CC3DP0VBKTGo9lzegyRfmtAEgdtxMCgYBSPR/c +mhox1QDjhThvNxaxSr3igJ4/bXqyhGHSJvvOKtjoVAerF/cZuZrrZmj0dwZsoK9g +tTKpojrd9luh/FZtr0DHN16+SDJ2fedu73rIpbcGvFrOkM818+iy9+3oBuHG09xU +Pg6EcRzadKjEwFDBf5A4ntA+sXK6V1j+6ECOdwKBgDJ/gjilnFni0VECZKulaWNo +3uYD8F9vZooSFUr7+amu+Diue/PUPA4NDFhXvrfzxIuBvUWuNJXTXuyw4XpWKC1H +WwXzuj9P0Ssjg5YYP8htl4YH1rn7lFJ16lraerynZdZS+1vyggUChqkrezzrnX5u +BkYpQzVIQ+JKuHbWG/iX +-----END PRIVATE KEY----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_us.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_us.pem new file mode 100644 index 0000000000..97641069ab --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_us.pem @@ -0,0 +1,50 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDM4i6AxiqeCuRN +Olom7e6v5WvYajTNjHTmM49wCeCTS6qzv7SK9mB5rvRJPveSipBp1QngzuJ5fUdW +c1bmrDN8CtoGgxKtb8TJnznF1/O5Rc5eUgLblB+eVwlokcE0iA3xa6nwhOOJIvxy +WvrlqmRA1Vbio67uzyK0dbIc+2Wl/bcFWig0ngUHmWcb7M70y3COh3XREu7dZVBH +ddkW9mrQa5nV5A+hjSpSjzt2BkhWQlLJoFaAQJcc+VZdkV7UOLjq3mqklm1xGjd8 +Hz6285opd81x7lnb3cKxcP9nOe+YItLhkHOZDtbCGn6eC3vNUb61SiWWYz3jysd0 +zs9W/OkRAgMBAAECggEAEenMqxGodDz7jQ0HCNRQxCxI65kNmNMo/V+QsPkMG5TI +DgM649N6x+jWDvlkZ/mZesdXn8I25uxfKl3XQT/ZGIVSXrNjFKrrB73WLUgeVRqX +WoPNICmpQgzWE8B6pf8nSO5i41abFkhqzgzKVv442bpNaG9rQhH2NNT9h68rSgHm +r4TL3mjYsHH3KhsE3AZ6hkistmHFrI34gqgiVVJc9Wkc1PUXrQv0MIailOAA0pci +vd+NCylS5YrZuPA+yvYnNhZg8YI31Jz6NlJX1gef54mQHI9777kVZ621nmyu6z+J +4421PKljuAa6McFZ68ZaoKDePeZ18i9oPT9C+pgMgQKBgQD23dLMIVqOh0JWOJGm +0m/i8rtGPfmGQ5nzL9lfE0btfdfg9MgxNcJgEjX+sBHCccSgBZkJMDcMDvd++uPK +ezqGUipHZn6IHO3qDej38GoAkXS4vfvokQZwFmbY2YZZHHeWcvXfCXUrCeWFTiyh +EF+bNoGOKYoD9iXZFLK8Wk1BaQKBgQDUdrhDic4AldhhCSUN6CDSpTcwEENQMUQI +n9kDu9BFIPtzoGt0h6/ttB1o76hRPIG/xDG0TmmYr+2uHPuv53iEE6xoOqVfhz9j +LMemGZJcEO6iac0R/JxMZ/f0Jtdb3qcqEMzDGRapEKXRhxyc2sckKouGDl6jmwU8 +cRv5hwzNaQKBgF8Y6IFWP+oz1KweMo2O9yb31oiiiyLbm8yAnD3x0RrfpW+1HAFI +8k3L9hzceprq9JGoVmGhvzLX+SNjRqjTe6IOuNa0dv28FzJYlWwdotgBMHOSTB40 +78Px/UB/2y8stiywsGJw5D/mGhKWIL8S7aF/B71Z5x7LjZzoSkoghy4xAoGAIlGS +7gXiaqHJs9LgU2C9fIkHYxIdbWSn0qTPTxUVTTVIqKKu+MG6HzFK1tpvW7/kTW+J +f9ByqiEwyUERPparFtp+rM9cSxPznzdqgF/9DjYF9eIVJ+Sf8o+Qs7VacBKE+fyA +trrkuCZiQngKnSORqzvcEalcjBzMObkjhajU3pECgYA9tsxLCCKS/MCPPLoKg0mQ +dFlR5wlXA1PXvOQrKg2LXedhtyyRGoxz23+dsCAMO7LPPoPYAvygCCPiLvv1pdf8 +Iu3YB/LUMw7GNzy2JAa2tTSUmta6fpEwA5lxTyWL/SlRmqUR5e4YNLiFAdMrM6M/ +DwDIoMrnaONIW7ZYbMvYqA== +-----END PRIVATE KEY----- +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUowDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUzWhcNMzYwNjA5MTYxOTUzWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdXMucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMziLoDGKp4K5E06Wibt +7q/la9hqNM2MdOYzj3AJ4JNLqrO/tIr2YHmu9Ek+95KKkGnVCeDO4nl9R1ZzVuas +M3wK2gaDEq1vxMmfOcXX87lFzl5SAtuUH55XCWiRwTSIDfFrqfCE44ki/HJa+uWq +ZEDVVuKjru7PIrR1shz7ZaX9twVaKDSeBQeZZxvszvTLcI6HddES7t1lUEd12Rb2 +atBrmdXkD6GNKlKPO3YGSFZCUsmgVoBAlxz5Vl2RXtQ4uOreaqSWbXEaN3wfPrbz +mil3zXHuWdvdwrFw/2c575gi0uGQc5kO1sIafp4Le81RvrVKJZZjPePKx3TOz1b8 +6RECAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1cy5waHAubmV0MB0GA1UdDgQWBBRd +iVHN0GR/1hByJ6K1z0TMxERm9zAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAfCpLwnWxEGycUvviyrborc+oxABVbWV8 +WjWnqmrVfC71KEkj0F3dDutN/FrIETMIMPL+OqYLU+DtSPFtQT98KQD/4nUSGvmC +ceyaLiuWA+GpC7d+KrsqJyGr0vK04ueGXSKaTSpvsf/+EJjhyaqBq3EKH7YAytjo +7uekke04UQ5wYzmNc3dBN4xXXocIRima5nPfSfCtApa9mWVHlNMDt3BR4/i6tkrA +VT5rjgqy2/IZtyMksb0DCEz+Ek+JmJq/vPUzFB5Aeqlp7AcnKuErlUzFecQzeD/M +806UzOgn5ALPF+fgwxFxIlXPAdqhzCGQAYvYipb7RiY1J2ZErlJNog== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_us_cert.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_us_cert.pem new file mode 100644 index 0000000000..65019fa657 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_us_cert.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDtDCCApygAwIBAgIUU6xv1wVKYOhdk0EtLBZNLFbqGUowDQYJKoZIhvcNAQEL +BQAwVTELMAkGA1UEBhMCR0IxEDAOBgNVBAgMB0VuZ2xhbmQxEDAOBgNVBAoMB1BI +UC5uZXQxEDAOBgNVBAsMB29wZW5zc2wxEDAOBgNVBAMMB3BocC5uZXQwHhcNMjYw +NjEyMTYxOTUzWhcNMzYwNjA5MTYxOTUzWjBGMQswCQYDVQQGEwJHQjEQMA4GA1UE +CAwHRW5nbGFuZDEQMA4GA1UECgwHUEhQLm5ldDETMBEGA1UEAwwKdXMucGhwLm5l +dDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAMziLoDGKp4K5E06Wibt +7q/la9hqNM2MdOYzj3AJ4JNLqrO/tIr2YHmu9Ek+95KKkGnVCeDO4nl9R1ZzVuas +M3wK2gaDEq1vxMmfOcXX87lFzl5SAtuUH55XCWiRwTSIDfFrqfCE44ki/HJa+uWq +ZEDVVuKjru7PIrR1shz7ZaX9twVaKDSeBQeZZxvszvTLcI6HddES7t1lUEd12Rb2 +atBrmdXkD6GNKlKPO3YGSFZCUsmgVoBAlxz5Vl2RXtQ4uOreaqSWbXEaN3wfPrbz +mil3zXHuWdvdwrFw/2c575gi0uGQc5kO1sIafp4Le81RvrVKJZZjPePKx3TOz1b8 +6RECAwEAAaOBijCBhzAJBgNVHRMEAjAAMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUE +DDAKBggrBgEFBQcDATAVBgNVHREEDjAMggp1cy5waHAubmV0MB0GA1UdDgQWBBRd +iVHN0GR/1hByJ6K1z0TMxERm9zAfBgNVHSMEGDAWgBSKFIRyLSDtCkYP1agron/c +Ogop4TANBgkqhkiG9w0BAQsFAAOCAQEAfCpLwnWxEGycUvviyrborc+oxABVbWV8 +WjWnqmrVfC71KEkj0F3dDutN/FrIETMIMPL+OqYLU+DtSPFtQT98KQD/4nUSGvmC +ceyaLiuWA+GpC7d+KrsqJyGr0vK04ueGXSKaTSpvsf/+EJjhyaqBq3EKH7YAytjo +7uekke04UQ5wYzmNc3dBN4xXXocIRima5nPfSfCtApa9mWVHlNMDt3BR4/i6tkrA +VT5rjgqy2/IZtyMksb0DCEz+Ek+JmJq/vPUzFB5Aeqlp7AcnKuErlUzFecQzeD/M +806UzOgn5ALPF+fgwxFxIlXPAdqhzCGQAYvYipb7RiY1J2ZErlJNog== +-----END CERTIFICATE----- diff --git a/tests/php-fixtures/openssl-sni-2036/sni_server_us_key.pem b/tests/php-fixtures/openssl-sni-2036/sni_server_us_key.pem new file mode 100644 index 0000000000..f53ba7f754 --- /dev/null +++ b/tests/php-fixtures/openssl-sni-2036/sni_server_us_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDM4i6AxiqeCuRN +Olom7e6v5WvYajTNjHTmM49wCeCTS6qzv7SK9mB5rvRJPveSipBp1QngzuJ5fUdW +c1bmrDN8CtoGgxKtb8TJnznF1/O5Rc5eUgLblB+eVwlokcE0iA3xa6nwhOOJIvxy +WvrlqmRA1Vbio67uzyK0dbIc+2Wl/bcFWig0ngUHmWcb7M70y3COh3XREu7dZVBH +ddkW9mrQa5nV5A+hjSpSjzt2BkhWQlLJoFaAQJcc+VZdkV7UOLjq3mqklm1xGjd8 +Hz6285opd81x7lnb3cKxcP9nOe+YItLhkHOZDtbCGn6eC3vNUb61SiWWYz3jysd0 +zs9W/OkRAgMBAAECggEAEenMqxGodDz7jQ0HCNRQxCxI65kNmNMo/V+QsPkMG5TI +DgM649N6x+jWDvlkZ/mZesdXn8I25uxfKl3XQT/ZGIVSXrNjFKrrB73WLUgeVRqX +WoPNICmpQgzWE8B6pf8nSO5i41abFkhqzgzKVv442bpNaG9rQhH2NNT9h68rSgHm +r4TL3mjYsHH3KhsE3AZ6hkistmHFrI34gqgiVVJc9Wkc1PUXrQv0MIailOAA0pci +vd+NCylS5YrZuPA+yvYnNhZg8YI31Jz6NlJX1gef54mQHI9777kVZ621nmyu6z+J +4421PKljuAa6McFZ68ZaoKDePeZ18i9oPT9C+pgMgQKBgQD23dLMIVqOh0JWOJGm +0m/i8rtGPfmGQ5nzL9lfE0btfdfg9MgxNcJgEjX+sBHCccSgBZkJMDcMDvd++uPK +ezqGUipHZn6IHO3qDej38GoAkXS4vfvokQZwFmbY2YZZHHeWcvXfCXUrCeWFTiyh +EF+bNoGOKYoD9iXZFLK8Wk1BaQKBgQDUdrhDic4AldhhCSUN6CDSpTcwEENQMUQI +n9kDu9BFIPtzoGt0h6/ttB1o76hRPIG/xDG0TmmYr+2uHPuv53iEE6xoOqVfhz9j +LMemGZJcEO6iac0R/JxMZ/f0Jtdb3qcqEMzDGRapEKXRhxyc2sckKouGDl6jmwU8 +cRv5hwzNaQKBgF8Y6IFWP+oz1KweMo2O9yb31oiiiyLbm8yAnD3x0RrfpW+1HAFI +8k3L9hzceprq9JGoVmGhvzLX+SNjRqjTe6IOuNa0dv28FzJYlWwdotgBMHOSTB40 +78Px/UB/2y8stiywsGJw5D/mGhKWIL8S7aF/B71Z5x7LjZzoSkoghy4xAoGAIlGS +7gXiaqHJs9LgU2C9fIkHYxIdbWSn0qTPTxUVTTVIqKKu+MG6HzFK1tpvW7/kTW+J +f9ByqiEwyUERPparFtp+rM9cSxPznzdqgF/9DjYF9eIVJ+Sf8o+Qs7VacBKE+fyA +trrkuCZiQngKnSORqzvcEalcjBzMObkjhajU3pECgYA9tsxLCCKS/MCPPLoKg0mQ +dFlR5wlXA1PXvOQrKg2LXedhtyyRGoxz23+dsCAMO7LPPoPYAvygCCPiLvv1pdf8 +Iu3YB/LUMw7GNzy2JAa2tTSUmta6fpEwA5lxTyWL/SlRmqUR5e4YNLiFAdMrM6M/ +DwDIoMrnaONIW7ZYbMvYqA== +-----END PRIVATE KEY----- diff --git a/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c b/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c index e1119e74da..272891754d 100644 --- a/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c +++ b/tests/sortix/os-test-local/basic/sys_socket/tcp-peer-close-eof-epipe.c @@ -1,4 +1,4 @@ -/* Test TCP peer close EOF and MSG_NOSIGNAL EPIPE behavior. */ +/* Test queued TCP data, EOF, and valid post-FIN send outcomes. */ #include @@ -22,22 +22,42 @@ int main(void) int server_fd; tcp_connected_pair(&client_fd, &server_fd); + const char queued[] = "queued-before-close"; + if ( send(server_fd, queued, sizeof(queued), 0) != (ssize_t) sizeof(queued) ) + err(1, "send queued data"); if ( close(server_fd) < 0 ) err(1, "close server"); - char byte; - ssize_t amount = recv(client_fd, &byte, sizeof(byte), 0); + char buffer[sizeof(queued)]; + ssize_t amount = recv(client_fd, buffer, sizeof(buffer), MSG_WAITALL); if ( amount < 0 ) - err(1, "recv"); + err(1, "recv queued data"); + if ( amount != (ssize_t) sizeof(queued) || + memcmp(buffer, queued, sizeof(queued)) != 0 ) + errx(1, "queued data was not delivered before EOF"); + + amount = recv(client_fd, buffer, sizeof(buffer), 0); + if ( amount < 0 ) + err(1, "recv EOF"); if ( amount != 0 ) errx(1, "recv did not report EOF after peer close"); const char payload[] = "after-close"; - amount = send(client_fd, payload, sizeof(payload), MSG_NOSIGNAL); - if ( amount >= 0 ) - errx(1, "send after peer close unexpectedly succeeded"); - if ( errno != EPIPE && errno != ECONNRESET ) - err(1, "send after peer close"); + /* + * TCP does not define which send observes a later reset. Accept either a + * locally queued write or the transport's truthful EPIPE/ECONNRESET; this + * test must not impose an invented operation count. + */ + for ( int i = 0; i < 3; i++ ) { + amount = send(client_fd, payload, sizeof(payload), MSG_NOSIGNAL); + if ( amount < 0 ) { + if ( errno != EPIPE && errno != ECONNRESET ) + err(1, "send after peer close"); + break; + } + if ( amount != (ssize_t) sizeof(payload) ) + errx(1, "short send after peer close"); + } if ( close(client_fd) < 0 ) err(1, "close client"); diff --git a/tests/sortix/os-test-local/udp.expect/datagram-flags.posix b/tests/sortix/os-test-local/udp.expect/datagram-flags.posix new file mode 100644 index 0000000000..9766475a41 --- /dev/null +++ b/tests/sortix/os-test-local/udp.expect/datagram-flags.posix @@ -0,0 +1 @@ +ok diff --git a/tests/sortix/os-test-local/udp.expect/queue-overflow-tail-drop.posix b/tests/sortix/os-test-local/udp.expect/queue-overflow-tail-drop.posix new file mode 100644 index 0000000000..9766475a41 --- /dev/null +++ b/tests/sortix/os-test-local/udp.expect/queue-overflow-tail-drop.posix @@ -0,0 +1 @@ +ok diff --git a/tests/sortix/os-test-local/udp.expect/unix-dgram-queue-backpressure.posix b/tests/sortix/os-test-local/udp.expect/unix-dgram-queue-backpressure.posix new file mode 100644 index 0000000000..9766475a41 --- /dev/null +++ b/tests/sortix/os-test-local/udp.expect/unix-dgram-queue-backpressure.posix @@ -0,0 +1 @@ +ok diff --git a/tests/sortix/os-test-local/udp/datagram-flags.c b/tests/sortix/os-test-local/udp/datagram-flags.c new file mode 100644 index 0000000000..b775e56338 --- /dev/null +++ b/tests/sortix/os-test-local/udp/datagram-flags.c @@ -0,0 +1,498 @@ +/* + * Exercise datagram flag semantics through the guest syscall ABI: + * IPv4 limited-broadcast permission, Linux MSG_TRUNC length reporting for + * IPv4/IPv6/Unix datagrams, and Kandelo's documented fixed buffer readback. + */ + +#include "udp.h" + +#include +#include +#include + +static void check_fixed_buffer_readback(int fd) +{ + int recv_capacity = 0; + int send_capacity = 0; + socklen_t value_len = sizeof(int); + if ( getsockopt(fd, + SOL_SOCKET, + SO_RCVBUF, + &recv_capacity, + &value_len) < 0 ) + err(1, "initial SO_RCVBUF"); + if ( value_len != sizeof(int) ) + errx(1, "initial SO_RCVBUF returned length %u", value_len); + value_len = sizeof(int); + if ( getsockopt(fd, + SOL_SOCKET, + SO_SNDBUF, + &send_capacity, + &value_len) < 0 ) + err(1, "initial SO_SNDBUF"); + if ( value_len != sizeof(int) ) + errx(1, "initial SO_SNDBUF returned length %u", value_len); + + int requested_recv = 1; + int requested_send = 1; + if ( setsockopt(fd, + SOL_SOCKET, + SO_RCVBUF, + &requested_recv, + sizeof(requested_recv)) < 0 ) + err(1, "advisory SO_RCVBUF request"); + if ( setsockopt(fd, + SOL_SOCKET, + SO_SNDBUF, + &requested_send, + sizeof(requested_send)) < 0 ) + err(1, "advisory SO_SNDBUF request"); + + int readback = 0; + value_len = sizeof(int); + if ( getsockopt(fd, + SOL_SOCKET, + SO_RCVBUF, + &readback, + &value_len) < 0 ) + err(1, "SO_RCVBUF readback"); + if ( value_len != sizeof(int) ) + errx(1, "SO_RCVBUF readback returned length %u", value_len); + if ( readback != recv_capacity ) + errx(1, + "SO_RCVBUF fabricated requested capacity: initial=%d readback=%d", + recv_capacity, + readback); + value_len = sizeof(int); + if ( getsockopt(fd, + SOL_SOCKET, + SO_SNDBUF, + &readback, + &value_len) < 0 ) + err(1, "SO_SNDBUF readback"); + if ( value_len != sizeof(int) ) + errx(1, "SO_SNDBUF readback returned length %u", value_len); + if ( readback != send_capacity ) + errx(1, + "SO_SNDBUF fabricated requested capacity: initial=%d readback=%d", + send_capacity, + readback); +} + +static void check_limited_broadcast_permission(void) +{ + int fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if ( fd < 0 ) + err(1, "broadcast socket"); + struct sockaddr_in broadcast; + memset(&broadcast, 0, sizeof(broadcast)); + broadcast.sin_family = AF_INET; + broadcast.sin_port = htobe16(9); + broadcast.sin_addr.s_addr = htobe32(INADDR_BROADCAST); + + errno = 0; + if ( sendto(fd, + "x", + 1, + 0, + (const struct sockaddr*) &broadcast, + sizeof(broadcast)) != -1 ) + errx(1, "limited broadcast unexpectedly sent without SO_BROADCAST"); + if ( errno != EACCES ) + err(1, "limited broadcast without SO_BROADCAST"); + + int enabled = 1; + if ( setsockopt(fd, + SOL_SOCKET, + SO_BROADCAST, + &enabled, + sizeof(enabled)) < 0 ) + err(1, "enable SO_BROADCAST"); + errno = 0; + ssize_t sent = sendto(fd, + "x", + 1, + 0, + (const struct sockaddr*) &broadcast, + sizeof(broadcast)); + if ( sent < 0 && errno != ENETUNREACH && errno != EHOSTUNREACH ) + err(1, "broadcast routing after SO_BROADCAST permission"); + if ( sent >= 0 && sent != 1 ) + errx(1, "broadcast send returned unexpected length %zd", sent); + + if ( close(fd) < 0 ) + err(1, "close broadcast socket"); +} + +static void check_ipv4_msg_trunc(void) +{ + int recv_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + int send_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if ( recv_fd < 0 || send_fd < 0 ) + err(1, "IPv4 sockets"); + check_fixed_buffer_readback(recv_fd); + + struct sockaddr_in recv_addr; + memset(&recv_addr, 0, sizeof(recv_addr)); + recv_addr.sin_family = AF_INET; + recv_addr.sin_addr.s_addr = htobe32(INADDR_LOOPBACK); + if ( bind(recv_fd, + (const struct sockaddr*) &recv_addr, + sizeof(recv_addr)) < 0 ) + err(1, "IPv4 receiver bind"); + socklen_t recv_addr_len = sizeof(recv_addr); + if ( getsockname(recv_fd, + (struct sockaddr*) &recv_addr, + &recv_addr_len) < 0 ) + err(1, "IPv4 receiver getsockname"); + + const char first[] = "0123456789"; + if ( sendto(send_fd, + first, + sizeof(first) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(first) - 1) ) + err(1, "IPv4 first send"); + unsigned char guarded[6]; + memset(guarded, 0xa5, sizeof(guarded)); + ssize_t received = recvfrom(recv_fd, + guarded, + 4, + MSG_PEEK | MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "IPv4 MSG_PEEK|MSG_TRUNC"); + if ( received != (ssize_t) (sizeof(first) - 1) ) + errx(1, "IPv4 MSG_PEEK|MSG_TRUNC returned %zd", received); + if ( memcmp(guarded, "0123", 4) != 0 || + guarded[4] != 0xa5 || guarded[5] != 0xa5 ) + errx(1, "IPv4 MSG_TRUNC overwrote its bounded destination"); + struct sockaddr_in from; + memset(&from, 0, sizeof(from)); + socklen_t from_len = sizeof(from); + received = recvfrom(recv_fd, + guarded, + 4, + MSG_TRUNC, + (struct sockaddr*) &from, + &from_len); + if ( received < 0 ) + err(1, "IPv4 MSG_TRUNC consume"); + if ( received != (ssize_t) (sizeof(first) - 1) ) + errx(1, "IPv4 MSG_TRUNC consume returned %zd", received); + if ( memcmp(guarded, "0123", 4) != 0 || + guarded[4] != 0xa5 || guarded[5] != 0xa5 ) + errx(1, "IPv4 consuming MSG_TRUNC overwrote its bounded destination"); + if ( from_len != sizeof(from) || from.sin_family != AF_INET ) + errx(1, + "IPv4 MSG_TRUNC source address malformed: family=%d length=%u", + from.sin_family, + from_len); + + const char second[] = "abcdefghij"; + if ( sendto(send_fd, + second, + sizeof(second) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(second) - 1) ) + err(1, "IPv4 second send"); + received = recvfrom(recv_fd, guarded, 4, 0, NULL, NULL); + if ( received < 0 ) + err(1, "IPv4 unflagged truncated receive"); + if ( received != 4 ) + errx(1, "IPv4 unflagged truncated receive returned %zd", received); + if ( memcmp(guarded, "abcd", 4) != 0 ) + errx(1, "IPv4 unflagged receive copied wrong prefix"); + + const char zero_buffer[] = "zero-buffer"; + if ( sendto(send_fd, + zero_buffer, + sizeof(zero_buffer) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(zero_buffer) - 1) ) + err(1, "IPv4 zero-buffer send"); + memset(guarded, 0xa5, sizeof(guarded)); + received = recvfrom(recv_fd, + guarded, + 0, + MSG_PEEK | MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "IPv4 zero-buffer peek"); + if ( received != (ssize_t) (sizeof(zero_buffer) - 1) ) + errx(1, "IPv4 zero-buffer peek returned %zd", received); + for ( size_t i = 0; i < sizeof(guarded); i++ ) + if ( guarded[i] != 0xa5 ) + errx(1, "IPv4 zero-buffer peek wrote byte %zu", i); + received = recvfrom(recv_fd, + guarded, + 0, + MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "IPv4 zero-buffer consume"); + if ( received != (ssize_t) (sizeof(zero_buffer) - 1) ) + errx(1, "IPv4 zero-buffer consume returned %zd", received); + for ( size_t i = 0; i < sizeof(guarded); i++ ) + if ( guarded[i] != 0xa5 ) + errx(1, "IPv4 zero-buffer consume wrote byte %zu", i); + errno = 0; + if ( recvfrom(recv_fd, + guarded, + 0, + MSG_DONTWAIT | MSG_TRUNC, + NULL, + NULL) != -1 ) + errx(1, "IPv4 zero-buffer consume did not dequeue the datagram"); + if ( errno != EAGAIN ) + err(1, "IPv4 receive after zero-buffer consume"); + + struct sockaddr_in send_addr; + memset(&send_addr, 0, sizeof(send_addr)); + socklen_t send_addr_len = sizeof(send_addr); + if ( getsockname(send_fd, + (struct sockaddr*) &send_addr, + &send_addr_len) < 0 ) + err(1, "IPv4 sender getsockname"); + send_addr.sin_addr.s_addr = htobe32(INADDR_LOOPBACK); + if ( connect(recv_fd, + (const struct sockaddr*) &send_addr, + send_addr_len) < 0 ) + err(1, "IPv4 receiver connect"); + const char connected[] = "connected-recv"; + if ( sendto(send_fd, + connected, + sizeof(connected) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(connected) - 1) ) + err(1, "IPv4 connected-recv send"); + received = recv(recv_fd, guarded, 4, MSG_TRUNC); + if ( received < 0 ) + err(1, "IPv4 connected recv MSG_TRUNC"); + if ( received != (ssize_t) (sizeof(connected) - 1) ) + errx(1, "IPv4 connected recv MSG_TRUNC returned %zd", received); + + const char recvmsg_payload[] = "recvmsg-trunc"; + if ( sendto(send_fd, + recvmsg_payload, + sizeof(recvmsg_payload) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(recvmsg_payload) - 1) ) + err(1, "IPv4 recvmsg send"); + memset(guarded, 0xa5, sizeof(guarded)); + struct iovec iov = { .iov_base = guarded, .iov_len = 4 }; + struct msghdr message; + memset(&message, 0, sizeof(message)); + message.msg_iov = &iov; + message.msg_iovlen = 1; + received = recvmsg(recv_fd, &message, MSG_TRUNC); + if ( received < 0 ) + err(1, "IPv4 recvmsg MSG_TRUNC"); + if ( received != (ssize_t) (sizeof(recvmsg_payload) - 1) ) + errx(1, "IPv4 recvmsg MSG_TRUNC returned %zd", received); + if ( memcmp(guarded, "recv", 4) != 0 || + guarded[4] != 0xa5 || guarded[5] != 0xa5 ) + errx(1, "IPv4 recvmsg MSG_TRUNC overwrote its bounded destination"); + + const char recvmsg_zero[] = "recvmsg-zero"; + if ( sendto(send_fd, + recvmsg_zero, + sizeof(recvmsg_zero) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(recvmsg_zero) - 1) ) + err(1, "IPv4 zero-buffer recvmsg send"); + iov.iov_len = 0; + received = recvmsg(recv_fd, &message, MSG_TRUNC); + if ( received < 0 ) + err(1, "IPv4 zero-buffer recvmsg MSG_TRUNC"); + if ( received != (ssize_t) (sizeof(recvmsg_zero) - 1) ) + errx(1, "IPv4 zero-buffer recvmsg MSG_TRUNC returned %zd", received); + for ( size_t i = 0; i < sizeof(guarded); i++ ) + if ( guarded[i] != (i < 4 ? (unsigned char) "recv"[i] : 0xa5) ) + errx(1, "IPv4 zero-buffer recvmsg wrote byte %zu", i); + + if ( close(send_fd) < 0 || close(recv_fd) < 0 ) + err(1, "close IPv4 sockets"); +} + +static void check_ipv6_msg_trunc(void) +{ + int recv_fd = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + int send_fd = socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if ( recv_fd < 0 || send_fd < 0 ) + err(1, "IPv6 sockets"); + + struct sockaddr_in6 recv_addr; + memset(&recv_addr, 0, sizeof(recv_addr)); + recv_addr.sin6_family = AF_INET6; + recv_addr.sin6_addr.s6_addr[15] = 1; + if ( bind(recv_fd, + (const struct sockaddr*) &recv_addr, + sizeof(recv_addr)) < 0 ) + err(1, "IPv6 receiver bind"); + socklen_t recv_addr_len = sizeof(recv_addr); + if ( getsockname(recv_fd, + (struct sockaddr*) &recv_addr, + &recv_addr_len) < 0 ) + err(1, "IPv6 receiver getsockname"); + + const char payload[] = "ipv6-truncated"; + if ( sendto(send_fd, + payload, + sizeof(payload) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != (ssize_t) (sizeof(payload) - 1) ) + err(1, "IPv6 send"); + char buf[4]; + ssize_t received = + recvfrom(recv_fd, buf, sizeof(buf), MSG_TRUNC, NULL, NULL); + if ( received < 0 ) + err(1, "IPv6 MSG_TRUNC"); + if ( received != (ssize_t) (sizeof(payload) - 1) ) + errx(1, "IPv6 MSG_TRUNC returned %zd", received); + if ( memcmp(buf, "ipv6", sizeof(buf)) != 0 ) + errx(1, "IPv6 MSG_TRUNC copied wrong prefix"); + + if ( close(send_fd) < 0 || close(recv_fd) < 0 ) + err(1, "close IPv6 sockets"); +} + +static void check_unix_msg_trunc(void) +{ + const char* recv_path = "/tmp/kandelo-dgram-flags-recv.sock"; + const char* send_path = "/tmp/kandelo-dgram-flags-send.sock"; + if ( unlink(recv_path) < 0 && errno != ENOENT ) + err(1, "unlink Unix receiver before bind"); + if ( unlink(send_path) < 0 && errno != ENOENT ) + err(1, "unlink Unix sender before bind"); + + int recv_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + int send_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + if ( recv_fd < 0 || send_fd < 0 ) + err(1, "Unix sockets"); + check_fixed_buffer_readback(recv_fd); + struct sockaddr_un recv_addr; + memset(&recv_addr, 0, sizeof(recv_addr)); + recv_addr.sun_family = AF_UNIX; + strncpy(recv_addr.sun_path, + recv_path, + sizeof(recv_addr.sun_path) - 1); + socklen_t recv_addr_len = + (socklen_t) (offsetof(struct sockaddr_un, sun_path) + + strlen(recv_addr.sun_path) + 1); + if ( bind(recv_fd, + (const struct sockaddr*) &recv_addr, + recv_addr_len) < 0 ) + err(1, "Unix receiver bind"); + struct sockaddr_un send_addr; + memset(&send_addr, 0, sizeof(send_addr)); + send_addr.sun_family = AF_UNIX; + strncpy(send_addr.sun_path, + send_path, + sizeof(send_addr.sun_path) - 1); + socklen_t send_addr_len = + (socklen_t) (offsetof(struct sockaddr_un, sun_path) + + strlen(send_addr.sun_path) + 1); + if ( bind(send_fd, + (const struct sockaddr*) &send_addr, + send_addr_len) < 0 ) + err(1, "Unix sender bind"); + if ( connect(send_fd, + (const struct sockaddr*) &recv_addr, + recv_addr_len) < 0 ) + err(1, "Unix sender connect"); + + const char payload[] = "reliable-unix"; + if ( send(send_fd, payload, sizeof(payload) - 1, 0) != + (ssize_t) (sizeof(payload) - 1) ) + err(1, "Unix initial send"); + char buf[4]; + ssize_t received = recvfrom(recv_fd, + buf, + sizeof(buf), + MSG_PEEK | MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "Unix MSG_PEEK|MSG_TRUNC"); + if ( received != (ssize_t) (sizeof(payload) - 1) ) + errx(1, "Unix MSG_PEEK|MSG_TRUNC returned %zd", received); + received = recvfrom(recv_fd, + buf, + sizeof(buf), + MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "Unix MSG_TRUNC consume"); + if ( received != (ssize_t) (sizeof(payload) - 1) ) + errx(1, "Unix MSG_TRUNC consume returned %zd", received); + if ( memcmp(buf, "reli", sizeof(buf)) != 0 ) + errx(1, "Unix MSG_TRUNC copied wrong prefix"); + + int flags = fcntl(send_fd, F_GETFL); + if ( flags < 0 || fcntl(send_fd, F_SETFL, flags | O_NONBLOCK) < 0 ) + err(1, "Unix sender nonblock"); + for ( uint32_t sequence = 0; sequence < 128; sequence++ ) + { + uint32_t value = htobe32(sequence); + if ( send(send_fd, &value, sizeof(value), 0) != + (ssize_t) sizeof(value) ) + err(1, "Unix queue fill at %u", sequence); + } + struct pollfd pfd = { .fd = send_fd, .events = POLLOUT, .revents = 0 }; + if ( poll(&pfd, 1, 0) != 0 || pfd.revents != 0 ) + errx(1, "full Unix queue unexpectedly writable: revents=%#x", pfd.revents); + unsigned char first_byte = 0; + received = recvfrom(recv_fd, + &first_byte, + sizeof(first_byte), + MSG_PEEK | MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "full Unix queue MSG_PEEK|MSG_TRUNC"); + if ( received != 4 ) + errx(1, "full Unix queue MSG_PEEK|MSG_TRUNC returned %zd", received); + pfd.revents = 0; + if ( poll(&pfd, 1, 0) != 0 || pfd.revents != 0 ) + errx(1, "MSG_PEEK released Unix queue capacity: revents=%#x", pfd.revents); + received = recvfrom(recv_fd, + &first_byte, + sizeof(first_byte), + MSG_TRUNC, + NULL, + NULL); + if ( received < 0 ) + err(1, "full Unix queue MSG_TRUNC consume"); + if ( received != 4 ) + errx(1, "full Unix queue MSG_TRUNC consume returned %zd", received); + pfd.revents = 0; + if ( poll(&pfd, 1, 0) != 1 || !(pfd.revents & POLLOUT) ) + errx(1, "MSG_TRUNC consume did not release sender: revents=%#x", pfd.revents); + + if ( close(send_fd) < 0 || close(recv_fd) < 0 ) + err(1, "close Unix sockets"); + if ( unlink(send_path) < 0 || unlink(recv_path) < 0 ) + err(1, "unlink Unix sockets"); +} + +int main(void) +{ + check_limited_broadcast_permission(); + check_ipv4_msg_trunc(); + check_ipv6_msg_trunc(); + check_unix_msg_trunc(); + puts("ok"); + return 0; +} diff --git a/tests/sortix/os-test-local/udp/queue-overflow-tail-drop.c b/tests/sortix/os-test-local/udp/queue-overflow-tail-drop.c new file mode 100644 index 0000000000..e3f04a0e29 --- /dev/null +++ b/tests/sortix/os-test-local/udp/queue-overflow-tail-drop.c @@ -0,0 +1,70 @@ +/* + * Fill Kandelo's documented 128-datagram UDP receive queue, then verify that + * later arrivals are dropped without evicting or reordering accepted data. + */ + +#include "udp.h" + +#include + +int main(void) +{ + int recv_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if ( recv_fd < 0 ) + err(1, "receiver socket"); + + struct sockaddr_in recv_addr; + memset(&recv_addr, 0, sizeof(recv_addr)); + recv_addr.sin_family = AF_INET; + recv_addr.sin_addr.s_addr = htobe32(INADDR_LOOPBACK); + if ( bind(recv_fd, (const struct sockaddr*) &recv_addr, + sizeof(recv_addr)) < 0 ) + err(1, "receiver bind"); + + socklen_t recv_addr_len = sizeof(recv_addr); + if ( getsockname(recv_fd, (struct sockaddr*) &recv_addr, + &recv_addr_len) < 0 ) + err(1, "receiver getsockname"); + + int send_fd = socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if ( send_fd < 0 ) + err(1, "sender socket"); + + for ( uint32_t sequence = 0; sequence < 130; sequence++ ) + { + uint32_t payload = htobe32(sequence); + ssize_t amount = sendto(send_fd, &payload, sizeof(payload), 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len); + if ( amount < 0 ) + err(1, "sendto"); + if ( amount != (ssize_t) sizeof(payload) ) + errx(1, "sendto returned %zi", amount); + } + + for ( uint32_t expected = 0; expected < 128; expected++ ) + { + uint32_t payload = 0; + ssize_t amount = recv(recv_fd, &payload, sizeof(payload), + MSG_DONTWAIT); + if ( amount < 0 ) + err(1, "recv sequence %u", expected); + if ( amount != (ssize_t) sizeof(payload) ) + errx(1, "recv returned %zi", amount); + uint32_t actual = be32toh(payload); + if ( actual != expected ) + errx(1, "sequence %u arrived as %u", expected, actual); + } + + uint32_t payload = 0; + errno = 0; + if ( recv(recv_fd, &payload, sizeof(payload), MSG_DONTWAIT) != -1 ) + errx(1, "overflow datagram unexpectedly remained queued"); + if ( errno != EAGAIN && errno != EWOULDBLOCK ) + err(1, "final recv"); + + if ( close(send_fd) < 0 || close(recv_fd) < 0 ) + err(1, "close"); + puts("ok"); + return 0; +} diff --git a/tests/sortix/os-test-local/udp/unix-dgram-queue-backpressure.c b/tests/sortix/os-test-local/udp/unix-dgram-queue-backpressure.c new file mode 100644 index 0000000000..1d0ec9a00a --- /dev/null +++ b/tests/sortix/os-test-local/udp/unix-dgram-queue-backpressure.c @@ -0,0 +1,461 @@ +/* + * AF_UNIX datagrams are reliable: a full receive queue must make a + * nonblocking sender report EAGAIN without discarding or reordering messages. + */ + +#include "udp.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +struct blocking_send_context +{ + int fd; + uint32_t payload; + _Atomic int started; + _Atomic int done; + ssize_t result; + int errnum; +}; + +struct blocking_poll_context +{ + int fd; + _Atomic int started; + _Atomic int done; + int result; + short revents; + int errnum; +}; + +static void* blocking_send_thread(void* context_ptr) +{ + struct blocking_send_context* context = context_ptr; + atomic_store_explicit(&context->started, 1, memory_order_release); + errno = 0; + context->result = send(context->fd, + &context->payload, + sizeof(context->payload), + MSG_NOSIGNAL); + context->errnum = errno; + atomic_store_explicit(&context->done, 1, memory_order_release); + return NULL; +} + +static void* blocking_poll_thread(void* context_ptr) +{ + struct blocking_poll_context* context = context_ptr; + struct pollfd pfd = { .fd = context->fd, .events = POLLOUT, .revents = 0 }; + atomic_store_explicit(&context->started, 1, memory_order_release); + errno = 0; + context->result = poll(&pfd, 1, 1000); + context->errnum = errno; + context->revents = pfd.revents; + atomic_store_explicit(&context->done, 1, memory_order_release); + return NULL; +} + +static void wait_for_thread_start(_Atomic int* started, const char* operation) +{ + for ( int attempt = 0; attempt < 2000; attempt++ ) + { + if ( atomic_load_explicit(started, memory_order_acquire) ) + return; + usleep(1000); + } + errx(1, "%s thread did not start", operation); +} + +static void join_thread(pthread_t thread, const char* operation) +{ + int error = pthread_join(thread, NULL); + if ( error ) + { + errno = error; + err(1, "join %s thread", operation); + } +} + +int main(void) +{ + const char* recv_path = "/tmp/kandelo-unix-dgram-overflow-recv.sock"; + const char* send_path = "/tmp/kandelo-unix-dgram-overflow-send.sock"; + if ( unlink(recv_path) < 0 && errno != ENOENT ) + err(1, "unlink receiver before bind"); + if ( unlink(send_path) < 0 && errno != ENOENT ) + err(1, "unlink sender before bind"); + + int recv_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + if ( recv_fd < 0 ) + err(1, "receiver socket"); + + struct sockaddr_un recv_addr; + memset(&recv_addr, 0, sizeof(recv_addr)); + recv_addr.sun_family = AF_UNIX; + strncpy(recv_addr.sun_path, recv_path, sizeof(recv_addr.sun_path) - 1); + socklen_t recv_addr_len = + (socklen_t) (offsetof(struct sockaddr_un, sun_path) + + strlen(recv_addr.sun_path) + 1); + if ( bind(recv_fd, + (const struct sockaddr*) &recv_addr, + recv_addr_len) < 0 ) + err(1, "receiver bind"); + + int send_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + if ( send_fd < 0 ) + err(1, "sender socket"); + struct sockaddr_un send_addr; + memset(&send_addr, 0, sizeof(send_addr)); + send_addr.sun_family = AF_UNIX; + strncpy(send_addr.sun_path, send_path, sizeof(send_addr.sun_path) - 1); + socklen_t send_addr_len = + (socklen_t) (offsetof(struct sockaddr_un, sun_path) + + strlen(send_addr.sun_path) + 1); + if ( bind(send_fd, + (const struct sockaddr*) &send_addr, + send_addr_len) < 0 ) + err(1, "sender bind"); + if ( connect(send_fd, + (const struct sockaddr*) &recv_addr, + recv_addr_len) < 0 ) + err(1, "sender connect"); + + int attacker_fd = socket(AF_UNIX, SOCK_DGRAM, 0); + if ( attacker_fd < 0 ) + err(1, "attacker socket"); + if ( connect(attacker_fd, + (const struct sockaddr*) &recv_addr, + recv_addr_len) < 0 ) + err(1, "attacker pre-connect connect"); + const char attacker_payload[] = "attacker-before-connect"; + if ( send(attacker_fd, + attacker_payload, + sizeof(attacker_payload) - 1, + 0) != (ssize_t) (sizeof(attacker_payload) - 1) ) + err(1, "attacker pre-connect send"); + const char peer_payload[] = "peer-before-connect"; + if ( send(send_fd, peer_payload, sizeof(peer_payload) - 1, 0) != + (ssize_t) (sizeof(peer_payload) - 1) ) + err(1, "peer pre-connect send"); + + if ( connect(recv_fd, + (const struct sockaddr*) &send_addr, + send_addr_len) < 0 ) + err(1, "receiver connect"); + char preconnect_buf[32]; + ssize_t preconnect_amount = + recv(recv_fd, preconnect_buf, sizeof(preconnect_buf), MSG_DONTWAIT); + if ( preconnect_amount != (ssize_t) (sizeof(peer_payload) - 1) || + memcmp(preconnect_buf, peer_payload, sizeof(peer_payload) - 1) != 0 ) + errx(1, "receiver did not preserve only its selected peer's datagram"); + + errno = 0; + if ( connect(attacker_fd, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != -1 ) + errx(1, "attacker connect unexpectedly succeeded"); + if ( errno != EPERM ) + err(1, "attacker connect to connected receiver"); + errno = 0; + if ( sendto(attacker_fd, + attacker_payload, + sizeof(attacker_payload) - 1, + 0, + (const struct sockaddr*) &recv_addr, + recv_addr_len) != -1 ) + errx(1, "attacker send unexpectedly succeeded"); + if ( errno != EPERM ) + err(1, "attacker send to connected receiver"); + + int flags = fcntl(send_fd, F_GETFL); + if ( flags < 0 || fcntl(send_fd, F_SETFL, flags | O_NONBLOCK) < 0 ) + err(1, "sender nonblock"); + + for ( uint32_t sequence = 0; sequence < 128; sequence++ ) + { + uint32_t payload = htobe32(sequence); + ssize_t amount = send(send_fd, &payload, sizeof(payload), 0); + if ( amount < 0 ) + err(1, "send sequence %u", sequence); + if ( amount != (ssize_t) sizeof(payload) ) + errx(1, "send returned %zi", amount); + } + // The receiver selected send_fd while attacker_fd was already connected. + // Even though the selected peer keeps the queue full, the rejected sender + // is writable because its next send now has an immediate EPERM result. + struct pollfd rejected_pfd = { + .fd = attacker_fd, + .events = POLLOUT, + .revents = 0, + }; + if ( poll(&rejected_pfd, 1, 0) != 1 || + !(rejected_pfd.revents & POLLOUT) ) + errx(1, + "rejected full-queue sender remained blocked: revents=%#x", + rejected_pfd.revents); + errno = 0; + if ( send(attacker_fd, + attacker_payload, + sizeof(attacker_payload) - 1, + 0) != -1 ) + errx(1, "rejected connected sender unexpectedly succeeded"); + if ( errno != EPERM ) + err(1, "rejected connected sender"); + + struct pollfd pfd = { .fd = send_fd, .events = POLLOUT, .revents = 0 }; + if ( poll(&pfd, 1, 0) != 0 || pfd.revents != 0 ) + errx(1, "full peer unexpectedly writable: revents=%#x", pfd.revents); + + // Finite readiness waits must keep one absolute deadline and perform a + // final readiness check instead of restarting their timeout on each retry. + pfd.revents = 0; + if ( poll(&pfd, 1, 120) != 0 || pfd.revents != 0 ) + errx(1, "finite poll reported full peer writable: revents=%#x", pfd.revents); + fd_set writefds; + FD_ZERO(&writefds); + FD_SET(send_fd, &writefds); + struct timeval timeout = { .tv_sec = 0, .tv_usec = 120000 }; + int select_result = select(send_fd + 1, NULL, &writefds, NULL, &timeout); + if ( select_result != 0 ) + errx(1, "finite select reported full peer writable: result=%d", select_result); + sigset_t blocked_mask; + sigset_t old_mask; + sigset_t wait_mask; + sigset_t after_mask; + sigemptyset(&blocked_mask); + sigaddset(&blocked_mask, SIGUSR1); + if ( sigprocmask(SIG_BLOCK, &blocked_mask, &old_mask) < 0 ) + err(1, "block SIGUSR1"); + sigemptyset(&wait_mask); + FD_ZERO(&writefds); + FD_SET(send_fd, &writefds); + struct timespec pselect_timeout = { .tv_sec = 0, .tv_nsec = 120000000 }; + int pselect_result = pselect(send_fd + 1, + NULL, + &writefds, + NULL, + &pselect_timeout, + &wait_mask); + if ( pselect_result != 0 ) + errx(1, + "finite pselect reported full peer writable: result=%d", + pselect_result); + if ( sigprocmask(SIG_BLOCK, NULL, &after_mask) < 0 ) + err(1, "read signal mask after pselect"); + if ( sigismember(&after_mask, SIGUSR1) != 1 ) + errx(1, "pselect did not restore the pre-wait signal mask"); + if ( sigprocmask(SIG_SETMASK, &old_mask, NULL) < 0 ) + err(1, "restore signal mask"); + if ( sigprocmask(SIG_BLOCK, &blocked_mask, &old_mask) < 0 ) + err(1, "block SIGUSR1 before ppoll"); + pfd.revents = 0; + struct timespec ppoll_timeout = { .tv_sec = 0, .tv_nsec = 120000000 }; + int ppoll_result = ppoll(&pfd, 1, &ppoll_timeout, &wait_mask); + if ( ppoll_result != 0 ) + errx(1, + "finite ppoll reported full peer writable: result=%d", + ppoll_result); + if ( sigprocmask(SIG_BLOCK, NULL, &after_mask) < 0 ) + err(1, "read signal mask after ppoll"); + if ( sigismember(&after_mask, SIGUSR1) != 1 ) + errx(1, "ppoll did not restore the pre-wait signal mask"); + if ( sigprocmask(SIG_SETMASK, &old_mask, NULL) < 0 ) + err(1, "restore signal mask after ppoll"); + int epoll_fd = epoll_create1(EPOLL_CLOEXEC); + if ( epoll_fd < 0 ) + err(1, "epoll_create1"); + struct epoll_event interest = { .events = EPOLLOUT, .data.fd = send_fd }; + if ( epoll_ctl(epoll_fd, EPOLL_CTL_ADD, send_fd, &interest) < 0 ) + err(1, "epoll_ctl"); + struct epoll_event event; + if ( epoll_wait(epoll_fd, &event, 1, 120) != 0 ) + errx(1, "finite epoll reported full peer writable"); + if ( close(epoll_fd) < 0 ) + err(1, "close epoll"); + + uint32_t payload = htobe32(128); + errno = 0; + if ( send(send_fd, &payload, sizeof(payload), 0) != -1 ) + errx(1, "send to full peer unexpectedly succeeded"); + if ( errno != EAGAIN && errno != EWOULDBLOCK ) + err(1, "send to full peer"); + + uint32_t first = 0; + if ( recv(recv_fd, &first, sizeof(first), MSG_DONTWAIT) != + (ssize_t) sizeof(first) ) + err(1, "recv first"); + if ( be32toh(first) != 0 ) + errx(1, "first sequence was %u", be32toh(first)); + + pfd.revents = 0; + if ( poll(&pfd, 1, 0) != 1 || !(pfd.revents & POLLOUT) ) + errx(1, "drained peer not writable: revents=%#x", pfd.revents); + if ( send(send_fd, &payload, sizeof(payload), 0) != + (ssize_t) sizeof(payload) ) + err(1, "retry send"); + + for ( uint32_t expected = 1; expected <= 128; expected++ ) + { + uint32_t actual = 0; + if ( recv(recv_fd, &actual, sizeof(actual), MSG_DONTWAIT) != + (ssize_t) sizeof(actual) ) + err(1, "recv sequence %u", expected); + if ( be32toh(actual) != expected ) + errx(1, "sequence %u arrived as %u", expected, be32toh(actual)); + } + + // Exercise a genuinely parked blocking send through a pthread's distinct + // syscall channel. Draining one slot must wake that sender, and the newly + // admitted datagram must remain at the tail of the preserved queue. + if ( fcntl(send_fd, F_SETFL, flags) < 0 ) + err(1, "restore blocking sender"); + struct timeval send_timeout = { .tv_sec = 1, .tv_usec = 0 }; + if ( setsockopt(send_fd, + SOL_SOCKET, + SO_SNDTIMEO, + &send_timeout, + sizeof(send_timeout)) < 0 ) + err(1, "set bounded sender timeout"); + const uint32_t blocking_base = 1000; + for ( uint32_t sequence = 0; sequence < 128; sequence++ ) + { + uint32_t blocking_payload = htobe32(blocking_base + sequence); + if ( send(send_fd, + &blocking_payload, + sizeof(blocking_payload), + 0) != (ssize_t) sizeof(blocking_payload) ) + err(1, "refill before blocking send at %u", sequence); + } + struct blocking_send_context send_context = { + .fd = send_fd, + .payload = htobe32(blocking_base + 128), + .started = 0, + .done = 0, + .result = -1, + .errnum = 0, + }; + pthread_t send_thread; + int thread_error = pthread_create(&send_thread, + NULL, + blocking_send_thread, + &send_context); + if ( thread_error ) + { + errno = thread_error; + err(1, "create blocking send thread"); + } + wait_for_thread_start(&send_context.started, "blocking send"); + usleep(20000); + if ( atomic_load_explicit(&send_context.done, memory_order_acquire) ) + errx(1, "send to full reliable queue did not remain blocked"); + uint32_t blocking_first = 0; + if ( recv(recv_fd, + &blocking_first, + sizeof(blocking_first), + MSG_DONTWAIT) != (ssize_t) sizeof(blocking_first) ) + err(1, "dequeue to wake blocked sender"); + if ( be32toh(blocking_first) != blocking_base ) + errx(1, + "blocking-send first sequence was %u", + be32toh(blocking_first)); + join_thread(send_thread, "blocking send"); + if ( send_context.result != (ssize_t) sizeof(send_context.payload) ) + { + if ( send_context.result < 0 ) + { + errno = send_context.errnum; + err(1, "blocked sender did not resume after dequeue"); + } + errx(1, + "blocked sender returned %zi after dequeue", + send_context.result); + } + for ( uint32_t expected = 1; expected <= 128; expected++ ) + { + uint32_t actual = 0; + if ( recv(recv_fd, &actual, sizeof(actual), MSG_DONTWAIT) != + (ssize_t) sizeof(actual) ) + err(1, "recv blocking-send sequence %u", expected); + if ( be32toh(actual) != blocking_base + expected ) + errx(1, + "blocking-send sequence %u arrived as %u", + blocking_base + expected, + be32toh(actual)); + } + + // A full reliable queue must not strand its sender after the receiver + // shuts down reads. The sender becomes writable only to report EPIPE, and + // MSG_NOSIGNAL must suppress the corresponding SIGPIPE delivery. + for ( uint32_t sequence = 0; sequence < 128; sequence++ ) + { + uint32_t shutdown_payload = htobe32(sequence); + if ( send(send_fd, + &shutdown_payload, + sizeof(shutdown_payload), + 0) != (ssize_t) sizeof(shutdown_payload) ) + err(1, "refill before read shutdown at %u", sequence); + } + pfd.revents = 0; + if ( poll(&pfd, 1, 0) != 0 || pfd.revents != 0 ) + errx(1, "refilled peer unexpectedly writable: revents=%#x", pfd.revents); + struct blocking_poll_context poll_context = { + .fd = send_fd, + .started = 0, + .done = 0, + .result = -1, + .revents = 0, + .errnum = 0, + }; + pthread_t poll_thread; + thread_error = pthread_create(&poll_thread, + NULL, + blocking_poll_thread, + &poll_context); + if ( thread_error ) + { + errno = thread_error; + err(1, "create blocking poll thread"); + } + wait_for_thread_start(&poll_context.started, "blocking poll"); + usleep(20000); + if ( atomic_load_explicit(&poll_context.done, memory_order_acquire) ) + errx(1, "POLLOUT wait on full reliable queue did not remain blocked"); + if ( shutdown(recv_fd, SHUT_RD) < 0 ) + err(1, "receiver SHUT_RD"); + join_thread(poll_thread, "blocking poll"); + if ( poll_context.result != 1 || !(poll_context.revents & POLLOUT) ) + { + if ( poll_context.result < 0 ) + { + errno = poll_context.errnum; + err(1, "read shutdown failed the POLLOUT waiter"); + } + errx(1, + "read shutdown did not wake POLLOUT waiter: result=%d revents=%#x", + poll_context.result, + poll_context.revents); + } + pfd.revents = 0; + if ( poll(&pfd, 1, 0) != 1 || !(pfd.revents & POLLOUT) ) + errx(1, "read-shut peer did not release sender: revents=%#x", pfd.revents); + errno = 0; + if ( send(send_fd, &payload, sizeof(payload), MSG_NOSIGNAL) != -1 ) + errx(1, "send to read-shut peer unexpectedly succeeded"); + if ( errno != EPIPE ) + err(1, "send to read-shut peer"); + + if ( close(attacker_fd) < 0 || close(send_fd) < 0 || close(recv_fd) < 0 ) + err(1, "close"); + if ( unlink(send_path) < 0 ) + err(1, "unlink sender after close"); + if ( unlink(recv_path) < 0 ) + err(1, "unlink receiver after close"); + puts("ok"); + return 0; +} diff --git a/tests/sortix/os-test-overrides/README.md b/tests/sortix/os-test-overrides/README.md new file mode 100644 index 0000000000..a96077c128 --- /dev/null +++ b/tests/sortix/os-test-overrides/README.md @@ -0,0 +1,11 @@ +# Sortix expectation overrides + +Directories named `.expect` replace the upstream expectation candidates +for a test. Use them when Kandelo has a stricter invariant that should not fall +back to another upstream platform's output. + +Directories named `.expect-additional` add narrowly documented Kandelo +outcomes while retaining the upstream candidates. These files are for valid +observable differences such as signal-handler output ordering; they must not +hide an unsupported POSIX behavior. Unsupported behavior belongs in the +runner's explicit XFAIL list and in `docs/posix-status.md`. diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-loopback-get-so-bindtodevice.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-loopback-get-so-bindtodevice.posix new file mode 100644 index 0000000000..d1a2f1f78e --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-loopback-get-so-bindtodevice.posix @@ -0,0 +1 @@ +exit: 0 diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-send-error-shutdown-rw-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-send-error-shutdown-rw-send.posix new file mode 100644 index 0000000000..be53b2a6ab --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-send-error-shutdown-rw-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +second send: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-send-error-shutdown-w-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-send-error-shutdown-w-send.posix new file mode 100644 index 0000000000..be53b2a6ab --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-send-error-shutdown-w-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +second send: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-reconnect-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-reconnect-send.posix new file mode 100644 index 0000000000..af56f1a26d --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-reconnect-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +send: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-send.posix new file mode 100644 index 0000000000..af56f1a26d --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +send: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-unconnect-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-unconnect-send.posix new file mode 100644 index 0000000000..b5a99af813 --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-rw-unconnect-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +sendto: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-w-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-w-send.posix new file mode 100644 index 0000000000..af56f1a26d --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-w-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +send: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-w-unconnect-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-w-unconnect-send.posix new file mode 100644 index 0000000000..b5a99af813 --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-shutdown-w-unconnect-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +sendto: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-unconnect-shutdown-rw-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-unconnect-shutdown-rw-send.posix new file mode 100644 index 0000000000..b5a99af813 --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-unconnect-shutdown-rw-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +sendto: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/connect-unconnect-shutdown-w-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/connect-unconnect-shutdown-w-send.posix new file mode 100644 index 0000000000..b5a99af813 --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/connect-unconnect-shutdown-w-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +sendto: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/get-so-bindtodevice.posix b/tests/sortix/os-test-overrides/udp.expect-additional/get-so-bindtodevice.posix new file mode 100644 index 0000000000..d1a2f1f78e --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/get-so-bindtodevice.posix @@ -0,0 +1 @@ +exit: 0 diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/shutdown-rw-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/shutdown-rw-send.posix new file mode 100644 index 0000000000..b5a99af813 --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/shutdown-rw-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +sendto: EPIPE diff --git a/tests/sortix/os-test-overrides/udp.expect-additional/shutdown-w-send.posix b/tests/sortix/os-test-overrides/udp.expect-additional/shutdown-w-send.posix new file mode 100644 index 0000000000..b5a99af813 --- /dev/null +++ b/tests/sortix/os-test-overrides/udp.expect-additional/shutdown-w-send.posix @@ -0,0 +1,2 @@ +SIGPIPE +sendto: EPIPE diff --git a/tools/xtask/src/archive_stage.rs b/tools/xtask/src/archive_stage.rs index b68c19a597..f0af1c8b52 100644 --- a/tools/xtask/src/archive_stage.rs +++ b/tools/xtask/src/archive_stage.rs @@ -16,6 +16,7 @@ use std::fs; use std::io::Write; use std::path::{Path, PathBuf}; +use crate::build_deps::validate_cache_artifacts; use crate::pkg_manifest::{DepsManifest, ManifestKind, TargetArch}; /// Caller-supplied build provenance + the locally-computed cache-key @@ -63,6 +64,13 @@ pub fn stage_archive_with_options( )); } + // A direct archive-stage invocation must enforce the same declared + // artifact closure as a resolver build/fetch. In particular, do not ship + // an archive whose manifest promises a runtime file that the payload + // omits; the consumer should never be the first place that notices. + validate_cache_artifacts(target, cache_dir) + .map_err(|e| format!("archive_stage: invalid cache entry: {e}"))?; + let manifest_text = build_archive_manifest_text(target, arch, abi_version, opts)?; // Pre-flight: enumerate cache_dir BEFORE touching any tmp file so @@ -140,19 +148,74 @@ pub fn stage_archive_with_options( Ok(()) } -/// Recursively collect every regular file under `dir`. Symlinks and -/// other special files are not packed — the wasm cache layout is all -/// regular files (`lib/*.a`, `include/*.h`, `lib/pkgconfig/*.pc`). +/// Recursively collect every archive leaf under `dir`. Contained symlinks are +/// deliberately dereferenced into regular archive entries so compatibility +/// aliases survive the existing tar format. External/cyclic symlinks and +/// special files fail producer preflight instead of smuggling bytes from +/// outside the cache root or disappearing from the fetched artifact. fn collect_files(dir: &Path, out: &mut Vec) -> Result<(), String> { - for entry in fs::read_dir(dir).map_err(|e| format!("read_dir {}: {e}", dir.display()))? { - let entry = entry.map_err(|e| format!("read_dir entry: {e}"))?; + let canonical_root = fs::canonicalize(dir) + .map_err(|e| format!("resolve archive cache root {}: {e}", dir.display()))?; + let mut active_dirs = std::collections::BTreeSet::new(); + collect_files_inner(dir, &canonical_root, &mut active_dirs, out) +} + +fn collect_files_inner( + dir: &Path, + canonical_root: &Path, + active_dirs: &mut std::collections::BTreeSet, + out: &mut Vec, +) -> Result<(), String> { + let resolved_dir = fs::canonicalize(dir) + .map_err(|e| format!("resolve archive directory {}: {e}", dir.display()))?; + if !resolved_dir.starts_with(canonical_root) { + return Err(format!( + "archive directory {} resolves outside cache root {}", + dir.display(), + canonical_root.display() + )); + } + if !active_dirs.insert(resolved_dir.clone()) { + return Err(format!( + "archive directory symlink cycle reaches {}", + dir.display() + )); + } + let mut entries = fs::read_dir(dir) + .map_err(|e| format!("read_dir {}: {e}", dir.display()))? + .collect::, _>>() + .map_err(|e| format!("read_dir {}: {e}", dir.display()))?; + entries.sort_by_key(|entry| entry.path()); + for entry in entries { let p = entry.path(); - if p.is_dir() { - collect_files(&p, out)?; - } else if p.is_file() { + let link_metadata = fs::symlink_metadata(&p) + .map_err(|e| format!("stat archive entry {}: {e}", p.display()))?; + let resolved = fs::canonicalize(&p) + .map_err(|e| format!("resolve archive entry {}: {e}", p.display()))?; + if !resolved.starts_with(canonical_root) { + return Err(format!( + "archive entry {} resolves outside cache root {}", + p.display(), + canonical_root.display() + )); + } + let metadata = if link_metadata.file_type().is_symlink() { + fs::metadata(&p).map_err(|e| format!("follow archive symlink {}: {e}", p.display()))? + } else { + link_metadata + }; + if metadata.is_dir() { + collect_files_inner(&p, canonical_root, active_dirs, out)?; + } else if metadata.is_file() { out.push(p); + } else { + return Err(format!( + "archive entry {} is not a regular file, directory, or contained symlink", + p.display() + )); } } + active_dirs.remove(&resolved_dir); Ok(()) } @@ -367,6 +430,181 @@ headers = ["include/zlib.h"] assert!(!install_root.join("artifacts").exists()); } + #[test] + fn program_runtime_file_round_trips_through_archive_and_fetch() { + use crate::pkg_manifest::Binary; + use crate::remote_fetch::fetch_and_install; + use sha2::{Digest, Sha256}; + use std::io::Read; + + let dir = tempdir("program-runtime-round-trip"); + let registry = dir.join("registry/php"); + fs::create_dir_all(®istry).unwrap(); + let manifest_text = r#" +kind = "program" +name = "php" +version = "8.3.15" +depends_on = [] + +[source] +url = "https://example.test/php.tar.gz" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + +[license] +spdx = "PHP-3.01" + +[[outputs]] +name = "php" +wasm = "php.wasm" + +[[runtime_files]] +artifact = "icu.dat" +guest_path = "/usr/lib/php/icu.dat" +mode = 420 +"#; + let manifest_path = registry.join("package.toml"); + fs::write(&manifest_path, manifest_text).unwrap(); + let manifest = DepsManifest::load(&manifest_path).unwrap(); + + let cache_dir = dir.join("cache_entry"); + fs::create_dir_all(&cache_dir).unwrap(); + fs::write( + cache_dir.join("php.wasm"), + b"\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b", + ) + .unwrap(); + let runtime_bytes = b"runtime-icu-data\0with-binary-bytes\xff"; + fs::write(cache_dir.join("icu.dat"), runtime_bytes).unwrap(); + + let archive_path = dir.join("php-out.tar.zst"); + let opts = StageOptions { + cache_key_sha: "c".repeat(64), + build_timestamp: "2026-07-12T00:00:00Z".to_string(), + build_host: "test-host".to_string(), + }; + stage_archive_with_options( + &manifest, + TargetArch::Wasm32, + 19, + &cache_dir, + &archive_path, + &opts, + ) + .unwrap(); + + let archive_bytes = fs::read(&archive_path).unwrap(); + let mut archive_hash = Sha256::new(); + archive_hash.update(&archive_bytes); + let binary = Binary { + archive_url: format!("file://{}", archive_path.display()), + archive_sha256: crate::util::hex(&Into::<[u8; 32]>::into(archive_hash.finalize())), + }; + let install_root = dir.join("install/canonical"); + fs::create_dir_all(install_root.parent().unwrap()).unwrap(); + fetch_and_install( + &binary, + &install_root, + &manifest, + TargetArch::Wasm32, + 19, + &opts.cache_key_sha, + ) + .unwrap(); + assert_eq!( + fs::read(install_root.join("icu.dat")).unwrap(), + runtime_bytes + ); + + // Pin the authored installation contract in the embedded manifest, + // not just the payload byte round-trip. + let decoder = zstd::stream::read::Decoder::new(&archive_bytes[..]).unwrap(); + let mut tar = tar::Archive::new(decoder); + let mut archived_manifest = None; + for entry in tar.entries().unwrap() { + let mut entry = entry.unwrap(); + if entry.path().unwrap().as_ref() == Path::new("manifest.toml") { + let mut text = String::new(); + entry.read_to_string(&mut text).unwrap(); + archived_manifest = Some(text); + break; + } + } + let parsed = DepsManifest::parse_archived( + &archived_manifest.expect("archive must contain manifest.toml"), + registry, + ) + .unwrap(); + assert_eq!(parsed.runtime_files, manifest.runtime_files); + + fs::remove_file(cache_dir.join("icu.dat")).unwrap(); + let incomplete_archive = dir.join("php-incomplete.tar.zst"); + let err = stage_archive_with_options( + &manifest, + TargetArch::Wasm32, + 19, + &cache_dir, + &incomplete_archive, + &opts, + ) + .unwrap_err(); + assert!( + err.contains("runtime file") && err.contains("icu.dat"), + "got: {err}" + ); + assert!(!incomplete_archive.exists()); + } + + #[cfg(unix)] + #[test] + fn archive_dereferences_contained_symlinks_and_rejects_external_ones() { + use std::io::Read; + use std::os::unix::fs::symlink; + + let (cache_dir, archive_path, manifest, opts) = fixture_for_round_trip("symlink-safety"); + symlink("zlib.h", cache_dir.join("include/zconf.h")).unwrap(); + stage_archive_with_options( + &manifest, + TargetArch::Wasm32, + 4, + &cache_dir, + &archive_path, + &opts, + ) + .unwrap(); + + let bytes = fs::read(&archive_path).unwrap(); + let decoder = zstd::stream::read::Decoder::new(&bytes[..]).unwrap(); + let mut tar = tar::Archive::new(decoder); + let mut alias_bytes = None; + for entry in tar.entries().unwrap() { + let mut entry = entry.unwrap(); + if entry.path().unwrap().as_ref() == Path::new("artifacts/include/zconf.h") { + let mut bytes = Vec::new(); + entry.read_to_end(&mut bytes).unwrap(); + alias_bytes = Some(bytes); + break; + } + } + assert_eq!(alias_bytes.as_deref(), Some(b"#ifndef ZLIB_H\n".as_slice())); + + fs::remove_file(cache_dir.join("include/zconf.h")).unwrap(); + let outside = archive_path.parent().unwrap().join("outside.dat"); + fs::write(&outside, b"outside").unwrap(); + symlink(&outside, cache_dir.join("include/zconf.h")).unwrap(); + let rejected = archive_path.parent().unwrap().join("external-link.tar.zst"); + let err = stage_archive_with_options( + &manifest, + TargetArch::Wasm32, + 4, + &cache_dir, + &rejected, + &opts, + ) + .unwrap_err(); + assert!(err.contains("outside cache root"), "got: {err}"); + assert!(!rejected.exists()); + } + #[test] fn embedded_manifest_round_trips_through_parse_archived() { use std::io::Read; @@ -483,7 +721,9 @@ headers = ["include/zlib.h"] stage_archive_with_options(&m, TargetArch::Wasm32, 4, &empty_cache, &archive, &opts) .unwrap_err(); assert!( - err.contains("contains no files") || err.contains("[outputs]"), + err.contains("missing from cache entry") + || err.contains("contains no files") + || err.contains("[outputs]"), "got: {err}" ); assert!( diff --git a/tools/xtask/src/build_deps.rs b/tools/xtask/src/build_deps.rs index 3053138ed7..cd14e4657e 100644 --- a/tools/xtask/src/build_deps.rs +++ b/tools/xtask/src/build_deps.rs @@ -309,7 +309,7 @@ pub fn compute_sha( // Fold in declared outputs so changing what a build is // expected to produce invalidates the cache. Without this, // renaming a program's `wasm = "..."` (or any library - // libs/headers/pkgconfig path) leaves cache_key_sha + // libs/headers/pkgconfig/files path) leaves cache_key_sha // unchanged — the resolver then serves a canonical // directory that doesn't match the new declaration and // archive-stage packs broken archives. Bug discovered in @@ -340,6 +340,17 @@ pub fn compute_sha( h.update(b"|"); } h.update(b"\n"); + // Preserve every existing package's cache key: the additive files + // field participates only when authored. A universally empty + // section would invalidate the entire package registry merely for + // learning a new output kind. + if !target.outputs.files.is_empty() { + h.update(b"outputs.files:v1\n"); + for s in &target.outputs.files { + h.update((s.len() as u64).to_le_bytes()); + h.update(s.as_bytes()); + } + } h.update(b"program_outputs:\n"); for out in &target.program_outputs { h.update(out.name.as_bytes()); @@ -351,6 +362,22 @@ pub fn compute_sha( } h.update(b"\n"); } + // Additive program runtime closure. Keep the section absent for + // existing manifests so learning this schema does not invalidate + // every historical package cache key. + if !target.runtime_files.is_empty() { + h.update(b"runtime_files:v1\n"); + for runtime_file in &target.runtime_files { + for field in [ + runtime_file.artifact.as_bytes(), + runtime_file.guest_path.as_bytes(), + ] { + h.update((field.len() as u64).to_le_bytes()); + h.update(field); + } + h.update(runtime_file.mode.to_le_bytes()); + } + } } } if !build_inputs.is_empty() { @@ -2059,7 +2086,7 @@ fn build_into_cache( } // Kind-aware validation. Library and program manifests carry a - // declared outputs list (libs/headers/pkgconfig or program wasms) + // declared outputs list (libs/headers/pkgconfig/files or program wasms) // that `validate_outputs` checks one-by-one. Source manifests have // no declared outputs — design 11 calls for emptiness as the only // signal — so we just verify the script populated OUT_DIR with at @@ -2350,24 +2377,209 @@ fn required_exports_for_program_output( } } -fn validate_cache_artifacts(target: &DepsManifest, dir: &Path) -> Result<(), String> { - if !matches!(target.kind, ManifestKind::Program) { - return Ok(()); +fn validate_declared_artifact( + target: &DepsManifest, + root: &Path, + rel: &str, + label: &str, + missing_suffix: &str, + require_regular_file: bool, +) -> Result { + let path = root.join(rel); + let metadata = std::fs::symlink_metadata(&path).map_err(|_| { + format!( + "{}: declared {} output {:?} {}", + target.spec(), + label, + rel, + missing_suffix + ) + })?; + if metadata.file_type().is_symlink() { + return Err(format!( + "{}: declared {} output {:?} must not be a symlink", + target.spec(), + label, + rel + )); } - for out in &target.program_outputs { - let path = dir.join(&out.wasm); - if !path.exists() { + let canonical_root = std::fs::canonicalize(root) + .map_err(|e| format!("{}: resolve package artifact root: {e}", target.spec()))?; + let resolved = std::fs::canonicalize(&path).map_err(|e| { + format!( + "{}: resolve declared {} output {:?}: {e}", + target.spec(), + label, + rel + ) + })?; + if !resolved.starts_with(&canonical_root) { + return Err(format!( + "{}: declared {} output {:?} resolves outside the package artifact root", + target.spec(), + label, + rel + )); + } + if require_regular_file && !metadata.is_file() { + return Err(format!( + "{}: declared {} output {:?} must be a regular file", + target.spec(), + label, + rel + )); + } + if !require_regular_file { + if metadata.is_file() { + return Ok(path); + } + if !metadata.is_dir() { return Err(format!( - "{}: declared wasm output {:?} missing from cache entry", + "{}: declared {} output {:?} must be a regular file or directory", target.spec(), - out.wasm + label, + rel )); } - validate_wasm_artifact_policy( - &path, - out.fork_instrumentation, - required_exports_for_program_output(target, out), - )?; + let mut active_dirs = BTreeSet::new(); + let leaf_count = validate_artifact_tree(&canonical_root, &path, &mut active_dirs)?; + if leaf_count == 0 { + return Err(format!( + "{}: declared {} output {:?} is an empty directory and cannot round-trip through an artifact archive", + target.spec(), + label, + rel + )); + } + } + Ok(path) +} + +/// Validate every reachable leaf below a declared artifact directory. +/// Internal symlinks are allowed because several library packages publish +/// compatibility aliases; external/cyclic links and special files are not. +fn validate_artifact_tree( + canonical_root: &Path, + path: &Path, + active_dirs: &mut BTreeSet, +) -> Result { + let link_metadata = std::fs::symlink_metadata(path) + .map_err(|e| format!("stat package artifact {}: {e}", path.display()))?; + let resolved = std::fs::canonicalize(path) + .map_err(|e| format!("resolve package artifact {}: {e}", path.display()))?; + if !resolved.starts_with(canonical_root) { + return Err(format!( + "package artifact {} resolves outside {}", + path.display(), + canonical_root.display() + )); + } + let metadata = if link_metadata.file_type().is_symlink() { + std::fs::metadata(path) + .map_err(|e| format!("follow package artifact symlink {}: {e}", path.display()))? + } else { + link_metadata + }; + if metadata.is_file() { + return Ok(1); + } + if !metadata.is_dir() { + return Err(format!( + "package artifact {} is not a regular file, directory, or contained symlink", + path.display() + )); + } + if !active_dirs.insert(resolved.clone()) { + return Err(format!( + "package artifact directory symlink cycle reaches {}", + path.display() + )); + } + let mut entries = std::fs::read_dir(path) + .map_err(|e| format!("read package artifact directory {}: {e}", path.display()))? + .collect::, _>>() + .map_err(|e| format!("read package artifact directory {}: {e}", path.display()))?; + entries.sort_by_key(|entry| entry.path()); + let mut leaves = 0usize; + for entry in entries { + leaves += validate_artifact_tree(canonical_root, &entry.path(), active_dirs)?; + } + active_dirs.remove(&resolved); + Ok(leaves) +} + +pub(crate) fn validate_cache_artifacts(target: &DepsManifest, dir: &Path) -> Result<(), String> { + match target.kind { + ManifestKind::Library => { + for rel in &target.outputs.libs { + validate_declared_artifact( + target, + dir, + rel, + "libs", + "missing from cache entry", + true, + )?; + } + for rel in &target.outputs.headers { + validate_declared_artifact( + target, + dir, + rel, + "headers", + "missing from cache entry", + false, + )?; + } + for rel in &target.outputs.pkgconfig { + validate_declared_artifact( + target, + dir, + rel, + "pkgconfig", + "missing from cache entry", + true, + )?; + } + for rel in &target.outputs.files { + validate_declared_artifact( + target, + dir, + rel, + "files", + "missing from cache entry", + true, + )?; + } + } + ManifestKind::Program => { + for out in &target.program_outputs { + let path = validate_declared_artifact( + target, + dir, + &out.wasm, + "wasm", + "missing from cache entry", + true, + )?; + validate_wasm_artifact_policy( + &path, + out.fork_instrumentation, + required_exports_for_program_output(target, out), + )?; + } + for runtime_file in &target.runtime_files { + validate_declared_artifact( + target, + dir, + &runtime_file.artifact, + "runtime file", + "missing from cache entry", + true, + )?; + } + } + ManifestKind::Source => {} } Ok(()) } @@ -2375,44 +2587,73 @@ fn validate_cache_artifacts(target: &DepsManifest, dir: &Path) -> Result<(), Str fn validate_outputs(target: &DepsManifest, out_dir: &Path) -> Result<(), String> { match target.kind { ManifestKind::Library => { - let check = |rel: &str, label: &str| -> Result<(), String> { - let p = out_dir.join(rel); - if !p.exists() { - return Err(format!( - "{}: declared {} output {:?} not produced by build script", - target.spec(), - label, - rel - )); - } - Ok(()) - }; for rel in &target.outputs.libs { - check(rel, "libs")?; + validate_declared_artifact( + target, + out_dir, + rel, + "libs", + "not produced by build script", + true, + )?; } for rel in &target.outputs.headers { - check(rel, "headers")?; + validate_declared_artifact( + target, + out_dir, + rel, + "headers", + "not produced by build script", + false, + )?; } for rel in &target.outputs.pkgconfig { - check(rel, "pkgconfig")?; + validate_declared_artifact( + target, + out_dir, + rel, + "pkgconfig", + "not produced by build script", + true, + )?; + } + for rel in &target.outputs.files { + validate_declared_artifact( + target, + out_dir, + rel, + "files", + "not produced by build script", + true, + )?; } } ManifestKind::Program => { for out in &target.program_outputs { - let p = out_dir.join(&out.wasm); - if !p.exists() { - return Err(format!( - "{}: declared wasm output {:?} not produced by build script", - target.spec(), - out.wasm - )); - } + let p = validate_declared_artifact( + target, + out_dir, + &out.wasm, + "wasm", + "not produced by build script", + true, + )?; validate_wasm_artifact_policy( &p, out.fork_instrumentation, required_exports_for_program_output(target, out), )?; } + for runtime_file in &target.runtime_files { + validate_declared_artifact( + target, + out_dir, + &runtime_file.artifact, + "runtime file", + "not produced by build script", + true, + )?; + } } // No outputs to validate for source-kind (Chunk C). ManifestKind::Source => return Ok(()), @@ -2599,7 +2840,7 @@ pub fn run(args: Vec) -> Result<(), String> { let mut it = rest.into_iter(); let sub = it.next().ok_or( "usage: xtask build-deps [--arch=wasm32|wasm64] [--binaries-dir ] [--fetch-only] \ - \ + \ [ []]", )?; let target = it.next(); @@ -2694,6 +2935,22 @@ pub fn run(args: Vec) -> Result<(), String> { })?; cmd_output_path(&manifest, &basename) } + "runtime-file-path" => { + let artifact = extra.ok_or_else(|| { + "build-deps runtime-file-path: missing \ + (usage: build-deps runtime-file-path )" + .to_string() + })?; + cmd_runtime_file_path(&manifest, &artifact) + } + "runtime-file-metadata" => { + let artifact = extra.ok_or_else(|| { + "build-deps runtime-file-metadata: missing \ + (usage: build-deps runtime-file-metadata )" + .to_string() + })?; + cmd_runtime_file_metadata(&manifest, &artifact) + } "output-fork-instrumentation" => { let basename = extra.ok_or_else(|| { "build-deps output-fork-instrumentation: missing \ @@ -2759,6 +3016,12 @@ fn cmd_parse(m: &DepsManifest) -> Result<(), String> { if !m.outputs.pkgconfig.is_empty() { println!("outputs.pkgconfig= {:?}", m.outputs.pkgconfig); } + if !m.outputs.files.is_empty() { + println!("outputs.files = {:?}", m.outputs.files); + } + if !m.runtime_files.is_empty() { + println!("runtime_files = {:?}", m.runtime_files); + } Ok(()) } @@ -2810,6 +3073,63 @@ fn cmd_output_path(m: &DepsManifest, wasm_basename: &str) -> Result<(), String> Ok(()) } +/// `runtime-file-path `: print the mirror path +/// below `programs//` used by local and resolver materialization. +fn cmd_runtime_file_path(m: &DepsManifest, artifact: &str) -> Result<(), String> { + let rel = m.runtime_file_dest_rel(artifact)?; + println!("{}", rel.display()); + Ok(()) +} + +/// Structured installation contract for VFS/image builders. JSON avoids +/// consumers scraping Debug output and keeps guest path/mode authoritative. +fn cmd_runtime_file_metadata(m: &DepsManifest, artifact: &str) -> Result<(), String> { + let value = runtime_file_metadata_value(m, artifact)?; + println!( + "{}", + serde_json::to_string(&value).map_err(|e| format!("serialize runtime metadata: {e}"))? + ); + Ok(()) +} + +fn runtime_file_metadata_value( + m: &DepsManifest, + artifact: &str, +) -> Result { + let runtime_file = m + .runtime_files + .iter() + .find(|runtime_file| runtime_file.artifact == artifact) + .ok_or_else(|| { + format!( + "program {:?} has no [[runtime_files]] artifact {:?}", + m.name, artifact + ) + })?; + // A runtime file is meaningful only alongside the exact executable and + // side-module outputs produced by the same program package archive. Give + // repo-side consumers the complete resolver mirror closure so they can + // select one materialization tier atomically instead of resolving each + // member independently and accidentally mixing builds. + let closure_mirror_paths: Vec = m + .program_outputs + .iter() + .map(|output| m.output_dest_rel_for(output)) + .chain( + m.runtime_files + .iter() + .map(|runtime_file| m.runtime_file_dest_rel_for(runtime_file)), + ) + .collect(); + Ok(serde_json::json!({ + "artifact": runtime_file.artifact, + "guest_path": runtime_file.guest_path, + "mode": runtime_file.mode, + "mirror_path": m.runtime_file_dest_rel_for(runtime_file), + "closure_mirror_paths": closure_mirror_paths, + })) +} + fn cmd_output_fork_instrumentation(m: &DepsManifest, wasm_basename: &str) -> Result<(), String> { let policy = m.output_fork_instrumentation(wasm_basename)?; println!("{}", policy.as_str()); @@ -2886,7 +3206,8 @@ fn cmd_resolve( } /// Place symlinks under `binaries_dir/programs//` pointing at -/// each declared `[[outputs]]` wasm in the cache canonical directory. +/// each declared `[[outputs]]` artifact and `[[runtime_files]]` file in the +/// cache canonical directory. /// /// Layout (per arch — wasm32 and wasm64 mirror in parallel): /// * 1 output: `/programs//.wasm`. @@ -2942,6 +3263,34 @@ fn place_binaries_symlinks( std::os::unix::fs::symlink(&src, &dest) .map_err(|e| format!("symlink {} -> {}: {e}", dest.display(), src.display()))?; } + for runtime_file in &m.runtime_files { + let src = canonical.join(&runtime_file.artifact); + let metadata = std::fs::symlink_metadata(&src).map_err(|e| { + format!( + "declared runtime file {} not found in cache at {}: {e}", + runtime_file.artifact, + src.display() + ) + })?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(format!( + "declared runtime file {} is not a regular non-symlink file at {}", + runtime_file.artifact, + src.display() + )); + } + let dest = arch_root.join(m.runtime_file_dest_rel_for(runtime_file)); + let dest_dir = dest + .parent() + .ok_or_else(|| format!("dest path {} has no parent", dest.display()))?; + std::fs::create_dir_all(dest_dir) + .map_err(|e| format!("mkdir {}: {e}", dest_dir.display()))?; + if dest.exists() || dest.symlink_metadata().is_ok() { + let _ = std::fs::remove_file(&dest); + } + std::os::unix::fs::symlink(&src, &dest) + .map_err(|e| format!("symlink {} -> {}: {e}", dest.display(), src.display()))?; + } Ok(()) } @@ -4134,6 +4483,70 @@ fork_instrumentation = "disabled" ); } + #[test] + fn cache_key_sha_tracks_program_runtime_file_contract() { + let root = tempdir("sha-prog-runtime-file"); + write_program_manifest( + &root, + "php", + "1.0.0", + "[[outputs]]\nname = \"php\"\nwasm = \"php.wasm\"\n", + ); + let reg = Registry { + roots: vec![root.clone()], + }; + let baseline = sha_of(®, "php"); + let toml_path = root.join("php/package.toml"); + let original = std::fs::read_to_string(&toml_path).unwrap(); + + let with_runtime = format!( + "{original}\n[[runtime_files]]\nartifact = \"icu.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"\n" + ); + std::fs::write(&toml_path, &with_runtime).unwrap(); + let added = sha_of(®, "php"); + assert_ne!( + baseline, added, + "adding runtime closure must invalidate the key" + ); + + std::fs::write( + &toml_path, + with_runtime.replace("/usr/lib/php/icu.dat", "/opt/php/icu.dat"), + ) + .unwrap(); + let moved = sha_of(®, "php"); + assert_ne!( + added, moved, + "changing the guest path must invalidate the key" + ); + + std::fs::write(&toml_path, format!("{with_runtime}mode = 384\n")).unwrap(); + let remoded = sha_of(®, "php"); + assert_ne!( + added, remoded, + "changing runtime mode must invalidate the key" + ); + + // Length prefixes keep delimiter-bearing fields unambiguous. These + // two records collide under naive `artifact|guest` concatenation. + std::fs::write( + &toml_path, + format!("{original}\n[[runtime_files]]\nartifact = \"a|/b\"\nguest_path = \"/c\"\n"), + ) + .unwrap(); + let delimiter_a = sha_of(®, "php"); + std::fs::write( + &toml_path, + format!("{original}\n[[runtime_files]]\nartifact = \"a\"\nguest_path = \"/b|/c\"\n"), + ) + .unwrap(); + let delimiter_b = sha_of(®, "php"); + assert_ne!( + delimiter_a, delimiter_b, + "runtime hash fields must be framed" + ); + } + /// Pins behavior: program outputs are hashed in declaration order. /// Re-ordering DOES change cache_key_sha. We deliberately don't /// normalize because (a) the manifest preserves authored order @@ -4254,6 +4667,69 @@ fork_instrumentation = "disabled" ); } + #[test] + fn cache_key_sha_changes_when_library_runtime_file_added() { + let root = tempdir("sha-lib-runtime-file-added"); + write(&root, "libZ", "1.0.0", &[]); + let reg = Registry { + roots: vec![root.clone()], + }; + let sha_before = sha_of(®, "libZ"); + + let toml_path = root.join("libZ/package.toml"); + let text = std::fs::read_to_string(&toml_path).unwrap(); + std::fs::write( + &toml_path, + text.replace( + "libs = [\"lib/liblibZ.a\"]", + "libs = [\"lib/liblibZ.a\"]\nfiles = [\"share/libZ.dat\"]", + ), + ) + .unwrap(); + let sha_after = sha_of(®, "libZ"); + + assert_ne!( + sha_before, sha_after, + "adding a library runtime file output must invalidate the cache key" + ); + } + + #[test] + fn library_runtime_file_cache_keys_frame_delimiter_bearing_paths() { + let root = tempdir("sha-lib-runtime-file-framing"); + write(&root, "libZ", "1.0.0", &[]); + let reg = Registry { + roots: vec![root.clone()], + }; + let toml_path = root.join("libZ/package.toml"); + let original = std::fs::read_to_string(&toml_path).unwrap(); + + std::fs::write( + &toml_path, + original.replace( + "libs = [\"lib/liblibZ.a\"]", + "libs = [\"lib/liblibZ.a\"]\nfiles = [\"a|b\", \"c\"]", + ), + ) + .unwrap(); + let delimiter_a = sha_of(®, "libZ"); + + std::fs::write( + &toml_path, + original.replace( + "libs = [\"lib/liblibZ.a\"]", + "libs = [\"lib/liblibZ.a\"]\nfiles = [\"a\", \"b|c\"]", + ), + ) + .unwrap(); + let delimiter_b = sha_of(®, "libZ"); + + assert_ne!( + delimiter_a, delimiter_b, + "library runtime-file cache-key fields must be length framed" + ); + } + // --- ensure_built / build_into_cache tests --- /// Create a package.toml + build-.sh pair. The build script uses @@ -4486,41 +4962,149 @@ libs = ["lib/libC.a"] } #[test] - fn ensure_built_fails_when_script_exits_nonzero() { - let root = tempdir("built-badexit"); - let cache = tempdir("built-badexit-cache"); + fn ensure_built_fails_when_declared_runtime_file_missing() { + let root = tempdir("built-missing-runtime-file"); + let cache = tempdir("built-missing-runtime-file-cache"); write_lib( &root, - "libD", + "libRuntimeMissing", "1.0.0", &[], - "echo boom >&2\nexit 37", + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR/lib" +touch "$WASM_POSIX_DEP_OUT_DIR/lib/libRuntimeMissing.a""#, r#"[outputs] -libs = ["lib/libD.a"] +libs = ["lib/libRuntimeMissing.a"] +files = ["share/runtime.dat"] "#, ); let reg = Registry { roots: vec![root] }; - let m = reg.load("libD").unwrap(); + let m = reg.load("libRuntimeMissing").unwrap(); let err = ensure_built(&m, ®, TEST_ARCH, TEST_ABI, &resolve_opts(&cache, None)).unwrap_err(); - assert!(err.contains("exited"), "got: {err}"); - let sha = compute_sha( - &m, - ®, - TEST_ARCH, - TEST_ABI, - &mut BTreeMap::new(), - &mut Vec::new(), - ) - .unwrap(); - assert!(!canonical_path(&cache, &m, TEST_ARCH, &sha).exists()); + assert!(err.contains("declared files output"), "got: {err}"); + assert!(err.contains("share/runtime.dat"), "got: {err}"); } - /// Regression: build-script stdout must NOT leak to xtask's stdout. - /// - /// `cmd_resolve` consumers shell-capture xtask's stdout to read the - /// canonical cache path: + #[test] + fn ensure_built_accepts_declared_runtime_file() { + let root = tempdir("built-runtime-file"); + let cache = tempdir("built-runtime-file-cache"); + write_lib( + &root, + "libRuntime", + "1.0.0", + &[], + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR/lib" "$WASM_POSIX_DEP_OUT_DIR/share" +touch "$WASM_POSIX_DEP_OUT_DIR/lib/libRuntime.a" +printf runtime > "$WASM_POSIX_DEP_OUT_DIR/share/runtime.dat""#, + r#"[outputs] +libs = ["lib/libRuntime.a"] +files = ["share/runtime.dat"] +"#, + ); + let reg = Registry { roots: vec![root] }; + let m = reg.load("libRuntime").unwrap(); + + let path = + ensure_built(&m, ®, TEST_ARCH, TEST_ABI, &resolve_opts(&cache, None)).unwrap(); + assert_eq!( + std::fs::read_to_string(path.join("share/runtime.dat")).unwrap(), + "runtime" + ); + } + + #[test] + fn ensure_built_rejects_declared_runtime_file_directory() { + let root = tempdir("built-runtime-file-directory"); + let cache = tempdir("built-runtime-file-directory-cache"); + write_lib( + &root, + "libRuntimeDirectory", + "1.0.0", + &[], + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR/lib" "$WASM_POSIX_DEP_OUT_DIR/share/runtime.dat" +touch "$WASM_POSIX_DEP_OUT_DIR/lib/libRuntimeDirectory.a""#, + r#"[outputs] +libs = ["lib/libRuntimeDirectory.a"] +files = ["share/runtime.dat"] +"#, + ); + let reg = Registry { roots: vec![root] }; + let m = reg.load("libRuntimeDirectory").unwrap(); + + let err = + ensure_built(&m, ®, TEST_ARCH, TEST_ABI, &resolve_opts(&cache, None)).unwrap_err(); + assert!(err.contains("must be a regular file"), "got: {err}"); + } + + #[cfg(unix)] + #[test] + fn ensure_built_rejects_declared_runtime_file_symlink_escape() { + let root = tempdir("built-runtime-file-symlink-escape"); + let cache = tempdir("built-runtime-file-symlink-escape-cache"); + let outside = root.join("outside.dat"); + std::fs::write(&outside, b"outside").unwrap(); + write_lib( + &root, + "libRuntimeSymlinkEscape", + "1.0.0", + &[], + &format!( + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR/lib" "$WASM_POSIX_DEP_OUT_DIR/share" +touch "$WASM_POSIX_DEP_OUT_DIR/lib/libRuntimeSymlinkEscape.a" +ln -s {:?} "$WASM_POSIX_DEP_OUT_DIR/share/runtime.dat""#, + outside + ), + r#"[outputs] +libs = ["lib/libRuntimeSymlinkEscape.a"] +files = ["share/runtime.dat"] +"#, + ); + let reg = Registry { roots: vec![root] }; + let m = reg.load("libRuntimeSymlinkEscape").unwrap(); + + let err = + ensure_built(&m, ®, TEST_ARCH, TEST_ABI, &resolve_opts(&cache, None)).unwrap_err(); + assert!(err.contains("must not be a symlink"), "got: {err}"); + } + + #[test] + fn ensure_built_fails_when_script_exits_nonzero() { + let root = tempdir("built-badexit"); + let cache = tempdir("built-badexit-cache"); + write_lib( + &root, + "libD", + "1.0.0", + &[], + "echo boom >&2\nexit 37", + r#"[outputs] +libs = ["lib/libD.a"] +"#, + ); + let reg = Registry { roots: vec![root] }; + let m = reg.load("libD").unwrap(); + + let err = + ensure_built(&m, ®, TEST_ARCH, TEST_ABI, &resolve_opts(&cache, None)).unwrap_err(); + assert!(err.contains("exited"), "got: {err}"); + let sha = compute_sha( + &m, + ®, + TEST_ARCH, + TEST_ABI, + &mut BTreeMap::new(), + &mut Vec::new(), + ) + .unwrap(); + assert!(!canonical_path(&cache, &m, TEST_ARCH, &sha).exists()); + } + + /// Regression: build-script stdout must NOT leak to xtask's stdout. + /// + /// `cmd_resolve` consumers shell-capture xtask's stdout to read the + /// canonical cache path: /// `PREFIX="$(cargo run -- build-deps resolve )"`. If the bash /// subprocess's stdout were inherited (the default), every chatty /// `echo` in the build script would land on xtask's stdout ahead of @@ -5205,6 +5789,49 @@ spdx = "TestLicense" name = "{output_name}" wasm = "{output_wasm}" +[compatibility] +target_arch = "{arch}" +abi_versions = [{abi_csv}] +cache_key_sha = "{cache_key_sha}" +"#, + "" + ) + } + + fn archived_program_runtime_manifest_text( + name: &str, + arch: &str, + abi_versions: &[u32], + cache_key_sha: &str, + ) -> String { + let abi_csv = abi_versions + .iter() + .map(|value| value.to_string()) + .collect::>() + .join(", "); + format!( + r#" +kind = "program" +name = "{name}" +version = "1.0.0" +revision = 1 +depends_on = [] + +[source] +url = "https://example.test/{name}.tar.gz" +sha256 = "{:0>64}" + +[license] +spdx = "MIT" + +[[outputs]] +name = "{name}" +wasm = "{name}.wasm" + +[[runtime_files]] +artifact = "icu.dat" +guest_path = "/usr/lib/php/icu.dat" + [compatibility] target_arch = "{arch}" abi_versions = [{abi_csv}] @@ -5287,6 +5914,33 @@ index_url = "{index_url}" std::fs::write(dir.join("build.toml"), build_toml).unwrap(); } + fn write_runtime_program_with_index(root: &Path, name: &str, index_url: &str) { + write_program( + root, + name, + "1.0.0", + &[], + &format!( + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR" +printf '\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b' > "$WASM_POSIX_DEP_OUT_DIR/{name}.wasm" +printf RUNTIME-BYTES > "$WASM_POSIX_DEP_OUT_DIR/icu.dat" +touch "$WASM_POSIX_DEP_OUT_DIR/via-build""#, + ), + &[(name, &format!("{name}.wasm"))], + ); + append_program_runtime_file(root, name, "icu.dat", "/usr/lib/php/icu.dat"); + let build_toml = format!( + r#"script_path = "{name}/build-{name}.sh" +repo_url = "https://example.test/repo.git" +commit = "deadbeefdeadbeefdeadbeefdeadbeefdeadbeef" + +[binary] +index_url = "{index_url}" +"#, + ); + fs::write(root.join(name).join("build.toml"), build_toml).unwrap(); + } + /// Stage an `index.toml` at `path` declaring `name@1.0.0` with a /// single Success entry for `arch` pointing at `archive_url` with /// the given `archive_sha256` and `cache_key_sha`. Mirrors what @@ -5655,6 +6309,185 @@ cache_key_sha = "{cache_key_hex}" ); } + #[test] + fn fetched_program_runtime_file_matches_source_mirror_layout() { + let root = tempdir("runtime-fetch-parity-reg"); + let remote_cache = tempdir("runtime-fetch-parity-remote-cache"); + let source_cache = tempdir("runtime-fetch-parity-source-cache"); + let remote_bin = tempdir("runtime-fetch-parity-remote-bin"); + let source_bin = tempdir("runtime-fetch-parity-source-bin"); + let archive_dir = tempdir("runtime-fetch-parity-archive"); + let index_dir = tempdir("runtime-fetch-parity-index"); + let index_path = index_dir.join("index.toml"); + let index_url = format!("file://{}", index_path.display()); + let name = "runtimeFetched"; + write_runtime_program_with_index(&root, name, &index_url); + + let reg = Registry { + roots: vec![root.clone()], + }; + let manifest = reg.load(name).unwrap(); + let cache_key_hex = hex(&compute_sha( + &manifest, + ®, + TEST_ARCH, + TEST_ABI, + &mut BTreeMap::new(), + &mut Vec::new(), + ) + .unwrap()); + let archived_manifest = archived_program_runtime_manifest_text( + name, + TEST_ARCH.as_str(), + &[TEST_ABI], + &cache_key_hex, + ); + let wasm_name = format!("{name}.wasm"); + let wasm_bytes = b"\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b"; + let archive_bytes = crate::remote_fetch::build_test_archive( + &archived_manifest, + &[ + (wasm_name.as_str(), wasm_bytes.as_slice()), + ("icu.dat", b"RUNTIME-BYTES"), + ], + ); + let archive_path = archive_dir.join(format!("{name}-1.0.0.tar.zst")); + fs::write(&archive_path, &archive_bytes).unwrap(); + stage_index_toml( + &index_path, + name, + TEST_ARCH, + &format!("file://{}", archive_path.display()), + &sha256_hex(&archive_bytes), + &cache_key_hex, + ); + + let remote_opts = ResolveOpts { + cache_root: &remote_cache, + local_libs: None, + force_source_build: None, + fetch_only: false, + repo_root: Some(&root), + binaries_dir: Some(&remote_bin), + }; + let remote_path = ensure_built(&manifest, ®, TEST_ARCH, TEST_ABI, &remote_opts).unwrap(); + assert!(!remote_path.join("via-build").exists()); + place_binaries_symlinks(&manifest, &remote_path, &remote_bin, TEST_ARCH).unwrap(); + + let force = BTreeSet::from([name.to_string()]); + let source_opts = ResolveOpts { + cache_root: &source_cache, + local_libs: None, + force_source_build: Some(&force), + fetch_only: false, + repo_root: Some(&root), + binaries_dir: Some(&source_bin), + }; + let source_path = ensure_built(&manifest, ®, TEST_ARCH, TEST_ABI, &source_opts).unwrap(); + assert!(source_path.join("via-build").exists()); + place_binaries_symlinks(&manifest, &source_path, &source_bin, TEST_ARCH).unwrap(); + + let mirror_rel = Path::new("programs/wasm32").join(name).join("icu.dat"); + assert_eq!( + fs::read(remote_bin.join(&mirror_rel)).unwrap(), + b"RUNTIME-BYTES" + ); + assert_eq!( + fs::read(source_bin.join(&mirror_rel)).unwrap(), + b"RUNTIME-BYTES" + ); + } + + #[test] + fn incomplete_fetched_runtime_file_falls_back_or_fails_fetch_only() { + let root = tempdir("runtime-fetch-incomplete-reg"); + let fallback_cache = tempdir("runtime-fetch-incomplete-fallback-cache"); + let fetch_only_cache = tempdir("runtime-fetch-incomplete-only-cache"); + let archive_dir = tempdir("runtime-fetch-incomplete-archive"); + let index_dir = tempdir("runtime-fetch-incomplete-index"); + let index_path = index_dir.join("index.toml"); + let index_url = format!("file://{}", index_path.display()); + let name = "runtimeIncomplete"; + write_runtime_program_with_index(&root, name, &index_url); + + let reg = Registry { + roots: vec![root.clone()], + }; + let manifest = reg.load(name).unwrap(); + let cache_key_hex = hex(&compute_sha( + &manifest, + ®, + TEST_ARCH, + TEST_ABI, + &mut BTreeMap::new(), + &mut Vec::new(), + ) + .unwrap()); + let archived_manifest = archived_program_runtime_manifest_text( + name, + TEST_ARCH.as_str(), + &[TEST_ABI], + &cache_key_hex, + ); + let wasm_name = format!("{name}.wasm"); + let wasm_bytes = b"\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b"; + let archive_bytes = crate::remote_fetch::build_test_archive( + &archived_manifest, + &[(wasm_name.as_str(), wasm_bytes.as_slice())], + ); + let archive_path = archive_dir.join(format!("{name}-1.0.0.tar.zst")); + fs::write(&archive_path, &archive_bytes).unwrap(); + stage_index_toml( + &index_path, + name, + TEST_ARCH, + &format!("file://{}", archive_path.display()), + &sha256_hex(&archive_bytes), + &cache_key_hex, + ); + + let fallback = ensure_built( + &manifest, + ®, + TEST_ARCH, + TEST_ABI, + &resolve_opts(&fallback_cache, None), + ) + .unwrap(); + assert!(fallback.join("via-build").exists()); + assert_eq!( + fs::read(fallback.join("icu.dat")).unwrap(), + b"RUNTIME-BYTES" + ); + + let fetch_only_opts = ResolveOpts { + cache_root: &fetch_only_cache, + local_libs: None, + force_source_build: None, + fetch_only: true, + repo_root: Some(&root), + binaries_dir: None, + }; + let err = ensure_built(&manifest, ®, TEST_ARCH, TEST_ABI, &fetch_only_opts).unwrap_err(); + assert!(err.contains("fetch-only"), "got: {err}"); + assert!(!canonical_path( + &fetch_only_cache, + &manifest, + TEST_ARCH, + &compute_sha( + &manifest, + ®, + TEST_ARCH, + TEST_ABI, + &mut BTreeMap::new(), + &mut Vec::new(), + ) + .unwrap(), + ) + .join("via-build") + .exists()); + } + #[test] fn index_fetch_falls_through_on_archive_sha_mismatch() { let root = tempdir("idx-shafail-reg"); @@ -5947,6 +6780,15 @@ spdx = "MIT" } } + fn append_program_runtime_file(root: &Path, name: &str, artifact: &str, guest_path: &str) { + let manifest_path = root.join(name).join("package.toml"); + let mut text = fs::read_to_string(&manifest_path).unwrap(); + text.push_str(&format!( + "\n[[runtime_files]]\nartifact = {artifact:?}\nguest_path = {guest_path:?}\n" + )); + fs::write(manifest_path, text).unwrap(); + } + #[test] fn canonical_path_uses_programs_subdir_for_program_kind() { let m = DepsManifest::parse( @@ -6010,6 +6852,141 @@ wasm = "vim.wasm" assert!(err.contains("miss.wasm"), "got: {err}"); } + #[test] + fn program_runtime_file_is_required_and_cached_as_a_regular_file() { + let root = tempdir("prog-runtime-file"); + let cache = tempdir("prog-runtime-file-cache"); + write_program( + &root, + "runtimeprog", + "0.1.0", + &[], + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR" +printf '\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b' > "$WASM_POSIX_DEP_OUT_DIR/runtimeprog.wasm" +printf runtime-data > "$WASM_POSIX_DEP_OUT_DIR/icu.dat""#, + &[("runtimeprog", "runtimeprog.wasm")], + ); + append_program_runtime_file(&root, "runtimeprog", "icu.dat", "/usr/lib/php/icu.dat"); + let reg = Registry { roots: vec![root] }; + let m = reg.load("runtimeprog").unwrap(); + assert_eq!( + runtime_file_metadata_value(&m, "icu.dat").unwrap(), + serde_json::json!({ + "artifact": "icu.dat", + "guest_path": "/usr/lib/php/icu.dat", + "mode": 420, + "mirror_path": "runtimeprog/icu.dat", + "closure_mirror_paths": [ + "runtimeprog.wasm", + "runtimeprog/icu.dat", + ], + }) + ); + let path = + ensure_built(&m, ®, TargetArch::Wasm32, 4, &resolve_opts(&cache, None)).unwrap(); + assert_eq!(fs::read(path.join("icu.dat")).unwrap(), b"runtime-data"); + + fs::remove_file(path.join("icu.dat")).unwrap(); + let err = validate_cache_artifacts(&m, &path).unwrap_err(); + assert!( + err.contains("runtime file") && err.contains("missing"), + "got: {err}" + ); + } + + #[test] + fn runtime_file_metadata_lists_the_complete_multi_output_closure() { + let manifest = DepsManifest::parse( + r#"kind = "program" +name = "runtimeprog" +version = "1.0" +depends_on = [] +[source] +url = "https://example.test/runtimeprog.tar.gz" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" +[license] +spdx = "MIT" +[[outputs]] +name = "runtimeprog" +wasm = "bin/runtimeprog.wasm" +[[outputs]] +name = "module" +wasm = "extensions/module.so" +[[runtime_files]] +artifact = "share/icu.dat" +guest_path = "/usr/lib/runtimeprog/icu.dat" +[[runtime_files]] +artifact = "share/timezone.dat" +guest_path = "/usr/lib/runtimeprog/timezone.dat" +"#, + PathBuf::from("/x"), + ) + .unwrap(); + + let metadata = runtime_file_metadata_value(&manifest, "share/icu.dat").unwrap(); + assert_eq!( + metadata["closure_mirror_paths"], + serde_json::json!([ + "runtimeprog/runtimeprog.wasm", + "runtimeprog/module.so", + "runtimeprog/share/icu.dat", + "runtimeprog/share/timezone.dat", + ]) + ); + } + + #[test] + fn build_fails_when_program_runtime_file_is_missing() { + let root = tempdir("prog-runtime-file-missing"); + let cache = tempdir("prog-runtime-file-missing-cache"); + write_program( + &root, + "runtimemissing", + "0.1.0", + &[], + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR" +printf '\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b' > "$WASM_POSIX_DEP_OUT_DIR/runtimemissing.wasm""#, + &[("runtimemissing", "runtimemissing.wasm")], + ); + append_program_runtime_file(&root, "runtimemissing", "icu.dat", "/usr/lib/php/icu.dat"); + let reg = Registry { roots: vec![root] }; + let m = reg.load("runtimemissing").unwrap(); + let err = + ensure_built(&m, ®, TargetArch::Wasm32, 4, &resolve_opts(&cache, None)).unwrap_err(); + assert!( + err.contains("runtime file") && err.contains("icu.dat"), + "got: {err}" + ); + } + + #[cfg(unix)] + #[test] + fn build_rejects_program_runtime_file_symlink() { + let root = tempdir("prog-runtime-file-symlink"); + let cache = tempdir("prog-runtime-file-symlink-cache"); + let outside = root.join("outside.dat"); + fs::write(&outside, b"outside").unwrap(); + write_program( + &root, + "runtimesymlink", + "0.1.0", + &[], + &format!( + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR" +printf '\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b' > "$WASM_POSIX_DEP_OUT_DIR/runtimesymlink.wasm" +ln -s {:?} "$WASM_POSIX_DEP_OUT_DIR/icu.dat""#, + outside + ), + &[("runtimesymlink", "runtimesymlink.wasm")], + ); + append_program_runtime_file(&root, "runtimesymlink", "icu.dat", "/usr/lib/php/icu.dat"); + let reg = Registry { roots: vec![root] }; + let m = reg.load("runtimesymlink").unwrap(); + let err = + ensure_built(&m, ®, TargetArch::Wasm32, 4, &resolve_opts(&cache, None)).unwrap_err(); + assert!(err.contains("must not be a symlink"), "got: {err}"); + } + #[test] fn program_output_validation_rejects_legacy_asyncify_wasm() { let out = tempdir("prog-out-asyncify"); @@ -7287,6 +8264,36 @@ libs = ["lib/libF3b.a"] ); } + #[test] + fn cmd_resolve_materializes_program_runtime_file_under_package_directory() { + let root = tempdir("resolve-bdir-runtime-reg"); + let cache = tempdir("resolve-bdir-runtime-cache"); + let bin_dir = tempdir("resolve-bdir-runtime-bin"); + write_program( + &root, + "runtimebin", + "0.1.0", + &[], + r#"mkdir -p "$WASM_POSIX_DEP_OUT_DIR" +printf '\x00asm\x01\x00\x00\x00\x01\x05\x01\x60\x00\x01\x7f\x03\x02\x01\x00\x07\x1a\x02\x0d__abi_version\x00\x00\x06_start\x00\x00\x0a\x06\x01\x04\x00\x41\x00\x0b' > "$WASM_POSIX_DEP_OUT_DIR/runtimebin.wasm" +printf canonical-runtime > "$WASM_POSIX_DEP_OUT_DIR/icu.dat""#, + &[("runtimebin", "runtimebin.wasm")], + ); + append_program_runtime_file(&root, "runtimebin", "icu.dat", "/usr/lib/php/icu.dat"); + let reg = Registry { + roots: vec![root.clone()], + }; + let m = reg.load("runtimebin").unwrap(); + + cmd_resolve_with_test_cache(&m, ®, &root, TargetArch::Wasm32, &cache, Some(&bin_dir)) + .unwrap(); + + let runtime = bin_dir.join("programs/wasm32/runtimebin/icu.dat"); + assert!(runtime.symlink_metadata().unwrap().file_type().is_symlink()); + assert_eq!(fs::read(runtime).unwrap(), b"canonical-runtime"); + assert!(bin_dir.join("programs/wasm32/runtimebin.wasm").exists()); + } + #[test] fn cmd_resolve_with_binaries_dir_places_kernel_at_root() { // First-party kernel/userspace artifacts are consumed as diff --git a/tools/xtask/src/dump_abi.rs b/tools/xtask/src/dump_abi.rs index 6c8b4916a5..5b1b2e9185 100644 --- a/tools/xtask/src/dump_abi.rs +++ b/tools/xtask/src/dump_abi.rs @@ -179,9 +179,13 @@ fn render_c_header() -> String { /* Default process-wasm pthread slot declaration. */\n\ #define WASM_POSIX_THREAD_SLOT_DECL_DEFAULT {thread_slots_default}\n\ \n\ + /* Fixed kernel/musl resource-usage wire record size. */\n\ + #define WASM_POSIX_RUSAGE_WIRE_SIZE {rusage_wire_size}u\n\ + \n\ #endif /* WASM_POSIX_ABI_CONSTANTS_H */\n", version = shared::ABI_VERSION, thread_slots_default = shared::process_memory::THREAD_SLOTS_USE_HOST_DEFAULT, + rusage_wire_size = shared::WASM_RUSAGE_WIRE_SIZE, ) } @@ -209,6 +213,10 @@ fn render_ts_module() -> String { "export const ABI_KERNEL_EXPORT = {:?} as const;\n\n", shared::abi::ABI_KERNEL_EXPORT )); + out.push_str(&format!( + "export const SCHED_AFFINITY_MASK_SIZE = {} as const;\n\n", + shared::SCHED_AFFINITY_MASK_SIZE + )); out.push_str(&format!( "export const HOST_ADAPTER_VERSION = {} as const;\n", @@ -425,6 +433,79 @@ fn render_ts_module() -> String { channel::SIG_OLD_MASK )); + out.push_str(&format!( + "export const WAIT_EVENT_EXITED = {} as const;\n", + shared::wait::EVENT_EXITED + )); + out.push_str(&format!( + "export const WAIT_EVENT_STOPPED = {} as const;\n", + shared::wait::EVENT_STOPPED + )); + out.push_str(&format!( + "export const WAIT_EVENT_CONTINUED = {} as const;\n", + shared::wait::EVENT_CONTINUED + )); + out.push_str(&format!( + "export const WAIT_WNOHANG = {} as const;\n", + shared::wait::WNOHANG + )); + out.push_str(&format!( + "export const WAIT_WUNTRACED = {} as const;\n", + shared::wait::WUNTRACED + )); + out.push_str(&format!( + "export const WAIT_WSTOPPED = {} as const;\n", + shared::wait::WSTOPPED + )); + out.push_str(&format!( + "export const WAIT_WEXITED = {} as const;\n", + shared::wait::WEXITED + )); + out.push_str(&format!( + "export const WAIT_WCONTINUED = {} as const;\n", + shared::wait::WCONTINUED + )); + out.push_str(&format!( + "export const WAIT_WNOWAIT = {} as const;\n", + shared::wait::WNOWAIT + )); + out.push_str(&format!( + "export const WAIT_CLD_EXITED = {} as const;\n", + shared::wait::CLD_EXITED + )); + out.push_str(&format!( + "export const WAIT_CLD_KILLED = {} as const;\n", + shared::wait::CLD_KILLED + )); + out.push_str(&format!( + "export const WAIT_CLD_STOPPED = {} as const;\n", + shared::wait::CLD_STOPPED + )); + out.push_str(&format!( + "export const WAIT_CLD_CONTINUED = {} as const;\n", + shared::wait::CLD_CONTINUED + )); + out.push_str(&format!( + "export const PROCESS_STATE_RUNNING = {} as const;\n", + shared::wait::PROCESS_STATE_RUNNING + )); + out.push_str(&format!( + "export const PROCESS_STATE_STOPPED = {} as const;\n", + shared::wait::PROCESS_STATE_STOPPED + )); + out.push_str(&format!( + "export const PROCESS_STATE_EXITED = {} as const;\n", + shared::wait::PROCESS_STATE_EXITED + )); + out.push_str(&format!( + "export const WAKE_PROCESS_STOPPED = {} as const;\n", + shared::wait::WAKE_PROCESS_STOPPED + )); + out.push_str(&format!( + "export const WAKE_PROCESS_CONTINUED = {} as const;\n\n", + shared::wait::WAKE_PROCESS_CONTINUED + )); + out.push_str(&format!( "export const STRUCT_SIZE_WASM_STAT = {} as const;\n", size_of::() @@ -442,9 +523,37 @@ fn render_ts_module() -> String { size_of::() )); out.push_str(&format!( - "export const STRUCT_SIZE_WASM_STATFS = {} as const;\n\n", + "export const STRUCT_SIZE_WASM_STATFS = {} as const;\n", size_of::() )); + out.push_str(&format!( + "export const STRUCT_SIZE_WASM_RUSAGE_WIRE = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const STRUCT_SIZE_KERNEL_WAIT_RESULT = {} as const;\n", + size_of::() + )); + out.push_str(&format!( + "export const KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET = {} as const;\n", + offset_of!(shared::KernelWaitResult, wait_status) + )); + out.push_str(&format!( + "export const KERNEL_WAIT_RESULT_SI_CODE_OFFSET = {} as const;\n", + offset_of!(shared::KernelWaitResult, si_code) + )); + out.push_str(&format!( + "export const KERNEL_WAIT_RESULT_SI_STATUS_OFFSET = {} as const;\n", + offset_of!(shared::KernelWaitResult, si_status) + )); + out.push_str(&format!( + "export const KERNEL_WAIT_RESULT_CHILD_UID_OFFSET = {} as const;\n", + offset_of!(shared::KernelWaitResult, child_uid) + )); + out.push_str(&format!( + "export const KERNEL_WAIT_RESULT_RUSAGE_OFFSET = {} as const;\n\n", + offset_of!(shared::KernelWaitResult, rusage) + )); out.push_str("export const HOST_INTERCEPTED_SYSCALLS = {\n"); for syscall in host_intercepted_syscall_metadata() { @@ -461,6 +570,12 @@ fn render_ts_module() -> String { } out.push_str("} as const;\n\n"); + out.push_str("export const PATHCONF_NAMES = {\n"); + for (name, number) in shared::pathconf::ABI_NAMES { + out.push_str(&format!(" {name}: {number},\n")); + } + out.push_str("} as const;\n\n"); + out.push_str("export const ABI_SYSCALL_NAMES: Record = {\n"); for (number, name) in all_syscall_log_names() { out.push_str(&format!(" {number}: {name:?},\n")); @@ -477,6 +592,8 @@ fn render_ts_module() -> String { out.push_str(" argIndex: number;\n"); out.push_str(" direction: SyscallArgDirection;\n"); out.push_str(" size: SyscallArgSizeSpec;\n"); + out.push_str(" nullable?: boolean;\n"); + out.push_str(" required?: boolean;\n"); out.push_str(" copyRetvalAdd?: number;\n"); out.push_str("}\n\n"); @@ -500,6 +617,12 @@ fn ts_syscall_arg_desc(desc: &shared::host_abi::SyscallArgDesc) -> String { syscall_arg_direction_name(desc.direction), ts_syscall_arg_size(desc.size) ); + if desc.nullable { + s.push_str(", nullable: true"); + } + if desc.required { + s.push_str(", required: true"); + } if desc.copy_retval_add != 0 { s.push_str(&format!(", copyRetvalAdd: {}", desc.copy_retval_add)); } @@ -663,6 +786,8 @@ fn build_snapshot(kernel_wasm: &std::path::Path) -> Result { root.insert("marshalled_structs".into(), marshalled_structs()); root.insert("syscalls".into(), syscalls()); + root.insert("pathconf_names".into(), pathconf_names()); + root.insert("wait_contract".into(), wait_contract()); root.insert( "host_intercepted_syscalls".into(), host_intercepted_syscalls(), @@ -892,7 +1017,10 @@ fn marshalled_structs() -> Value { }; use shared::fbdev::{FbBitfield, FbFixScreenInfo, FbVarScreenInfo}; use shared::gl::{GlContextAttrs, GlQueryInfo, GlSubmitInfo, GlSurfaceAttrs}; - use shared::{WasmDirent, WasmFlock, WasmPollFd, WasmStat, WasmStatfs, WasmTimespec}; + use shared::{ + KernelWaitResult, WasmDirent, WasmFlock, WasmPollFd, WasmRusageWire, WasmStat, + WasmStatfs, WasmTimespec, + }; let mut structs: JsonMap = BTreeMap::new(); structs.insert( @@ -963,6 +1091,39 @@ fn marshalled_structs() -> Value { _pad, }), ); + structs.insert( + "WasmRusageWire".into(), + struct_layout!(WasmRusageWire { + ru_utime_sec, + ru_utime_usec, + ru_stime_sec, + ru_stime_usec, + ru_maxrss, + ru_ixrss, + ru_idrss, + ru_isrss, + ru_minflt, + ru_majflt, + ru_nswap, + ru_inblock, + ru_oublock, + ru_msgsnd, + ru_msgrcv, + ru_nsignals, + ru_nvcsw, + ru_nivcsw, + }), + ); + structs.insert( + "KernelWaitResult".into(), + struct_layout!(KernelWaitResult { + wait_status, + si_code, + si_status, + child_uid, + rusage, + }), + ); structs.insert( "FbBitfield".into(), struct_layout!(FbBitfield { @@ -1281,6 +1442,62 @@ fn syscalls() -> Value { Value::Array(list) } +fn pathconf_names() -> Value { + let mut names: JsonMap = BTreeMap::new(); + for (name, number) in shared::pathconf::ABI_NAMES { + names.insert((*name).into(), json!(number)); + } + Value::Object(names.into_iter().collect()) +} + +fn wait_contract() -> Value { + let mut contract: JsonMap = BTreeMap::new(); + for (name, value) in [ + ("WAIT_EVENT_EXITED", json!(shared::wait::EVENT_EXITED)), + ("WAIT_EVENT_STOPPED", json!(shared::wait::EVENT_STOPPED)), + ( + "WAIT_EVENT_CONTINUED", + json!(shared::wait::EVENT_CONTINUED), + ), + ("WAIT_WNOHANG", json!(shared::wait::WNOHANG)), + ("WAIT_WUNTRACED", json!(shared::wait::WUNTRACED)), + ("WAIT_WSTOPPED", json!(shared::wait::WSTOPPED)), + ("WAIT_WEXITED", json!(shared::wait::WEXITED)), + ("WAIT_WCONTINUED", json!(shared::wait::WCONTINUED)), + ("WAIT_WNOWAIT", json!(shared::wait::WNOWAIT)), + ("WAIT_CLD_EXITED", json!(shared::wait::CLD_EXITED)), + ("WAIT_CLD_KILLED", json!(shared::wait::CLD_KILLED)), + ("WAIT_CLD_STOPPED", json!(shared::wait::CLD_STOPPED)), + ( + "WAIT_CLD_CONTINUED", + json!(shared::wait::CLD_CONTINUED), + ), + ( + "PROCESS_STATE_RUNNING", + json!(shared::wait::PROCESS_STATE_RUNNING), + ), + ( + "PROCESS_STATE_STOPPED", + json!(shared::wait::PROCESS_STATE_STOPPED), + ), + ( + "PROCESS_STATE_EXITED", + json!(shared::wait::PROCESS_STATE_EXITED), + ), + ( + "WAKE_PROCESS_STOPPED", + json!(shared::wait::WAKE_PROCESS_STOPPED), + ), + ( + "WAKE_PROCESS_CONTINUED", + json!(shared::wait::WAKE_PROCESS_CONTINUED), + ), + ] { + contract.insert(name.into(), value); + } + Value::Object(contract.into_iter().collect()) +} + #[derive(Debug, Clone, Copy)] struct HostInterceptedSyscall { constant_name: &'static str, @@ -1511,6 +1728,12 @@ fn syscall_arg_desc_json(desc: &shared::host_abi::SyscallArgDesc) -> Value { json!(syscall_arg_direction_name(desc.direction)), ); m.insert("size".into(), syscall_arg_size_json(desc.size)); + if desc.nullable { + m.insert("nullable".into(), json!(true)); + } + if desc.required { + m.insert("required".into(), json!(true)); + } if desc.copy_retval_add != 0 { m.insert("copyRetvalAdd".into(), json!(desc.copy_retval_add)); } @@ -1839,6 +2062,7 @@ fn classify_compat_change(old: &Value, new: &Value) -> Result { classify_additive_array_by_name(key, old_value, new_value, &mut report)? } + "host_adapter" => classify_host_adapter(old_value, new_value, &mut report)?, "marshalled_structs" => { classify_additive_object_by_key(key, old_value, new_value, &mut report)? } @@ -1861,6 +2085,57 @@ fn additive_top_level_section(section: &str) -> bool { matches!(section, "host_adapter" | "syscall_arg_descriptors") } +fn classify_host_adapter( + old: &Value, + new: &Value, + report: &mut CompatReport, +) -> Result<(), String> { + let old_obj = old + .as_object() + .ok_or("old host_adapter section must be a JSON object")?; + let new_obj = new + .as_object() + .ok_or("new host_adapter section must be a JSON object")?; + + for key in old_obj.keys() { + let Some(new_value) = new_obj.get(key) else { + report + .breaking + .push(format!("removed host_adapter field {key:?}")); + continue; + }; + if key == "optional_kernel_exports" { + classify_additive_array( + "host_adapter.optional_kernel_exports", + &old_obj[key], + new_value, + report, + |entry| { + entry.as_str().map(ToOwned::to_owned).ok_or_else(|| { + format!( + "host_adapter.optional_kernel_exports entry must be a string: {entry}", + ) + }) + }, + )?; + } else if &old_obj[key] != new_value { + report + .breaking + .push(format!("changed host_adapter field {key:?}")); + } + } + + for key in new_obj.keys() { + if !old_obj.contains_key(key) { + report + .breaking + .push(format!("added host_adapter field {key:?}")); + } + } + + Ok(()) +} + fn classify_additive_object_by_key( section: &str, old: &Value, @@ -2026,6 +2301,71 @@ mod tests { ); } + #[test] + fn generated_typescript_contains_pathconf_names_and_required_outputs() { + let rendered = render_ts_module(); + assert!(rendered.contains("export const SCHED_AFFINITY_MASK_SIZE = 4 as const;")); + assert!(rendered.contains("export const PATHCONF_NAMES = {")); + assert!(rendered.contains(" PATH_MAX: 4,")); + assert!(rendered.contains(" TIMESTAMP_RESOLUTION: 23,")); + assert!(rendered.contains( + "{ argIndex: 2, direction: \"out\", size: { type: \"fixed\", size: 8 }, required: true }" + )); + + let names = pathconf_names(); + assert_eq!(names["LINK_MAX"], json!(0)); + assert_eq!(names["TIMESTAMP_RESOLUTION"], json!(23)); + assert_eq!(names.as_object().unwrap().len(), 24); + } + + #[test] + fn generated_wait_abi_metadata_matches_shared_layouts() { + let rendered = render_ts_module(); + for expected in [ + "export const STRUCT_SIZE_WASM_RUSAGE_WIRE = 144 as const;", + "export const STRUCT_SIZE_KERNEL_WAIT_RESULT = 160 as const;", + "export const KERNEL_WAIT_RESULT_WAIT_STATUS_OFFSET = 0 as const;", + "export const KERNEL_WAIT_RESULT_SI_CODE_OFFSET = 4 as const;", + "export const KERNEL_WAIT_RESULT_SI_STATUS_OFFSET = 8 as const;", + "export const KERNEL_WAIT_RESULT_CHILD_UID_OFFSET = 12 as const;", + "export const KERNEL_WAIT_RESULT_RUSAGE_OFFSET = 16 as const;", + "export const WAIT_EVENT_EXITED = 1 as const;", + "export const WAIT_EVENT_STOPPED = 2 as const;", + "export const WAIT_EVENT_CONTINUED = 4 as const;", + "export const PROCESS_STATE_RUNNING = 0 as const;", + "export const PROCESS_STATE_STOPPED = 1 as const;", + "export const PROCESS_STATE_EXITED = 2 as const;", + "export const WAKE_PROCESS_STOPPED = 16 as const;", + "export const WAKE_PROCESS_CONTINUED = 32 as const;", + "\"kernel_get_process_state\"", + "\"kernel_has_sa_nocldstop\"", + "\"kernel_wait_child_poll\"", + ] { + assert!( + rendered.contains(expected), + "missing generated TS: {expected}" + ); + } + + let header = render_c_header(); + assert!(header.contains("#define WASM_POSIX_RUSAGE_WIRE_SIZE 144u")); + + let structs = marshalled_structs(); + assert_eq!(structs["WasmRusageWire"]["size"], json!(144)); + assert_eq!(structs["KernelWaitResult"]["size"], json!(160)); + assert_eq!( + structs["KernelWaitResult"]["fields"][4], + json!({"name": "rusage", "offset": 16, "span": 144}) + ); + + let contract = wait_contract(); + assert_eq!(contract["WAIT_WNOWAIT"], json!(0x0100_0000)); + assert_eq!(contract["WAIT_CLD_CONTINUED"], json!(6)); + assert_eq!(contract["PROCESS_STATE_STOPPED"], json!(1)); + assert_eq!(contract["WAKE_PROCESS_CONTINUED"], json!(32)); + assert_eq!(contract.as_object().unwrap().len(), 18); + } + fn base_snapshot() -> Value { json!({ "abi_version": 10, @@ -2146,6 +2486,50 @@ mod tests { ); } + #[test] + fn adding_wait_contract_section_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["wait_contract"] = wait_contract(); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["added top-level section \"wait_contract\""] + ); + } + + #[test] + fn adding_optional_host_adapter_export_is_compatible() { + let old = base_snapshot(); + let mut new = old.clone(); + new["host_adapter"]["optional_kernel_exports"] = + json!(["kernel_get_process_exit_signal",]); + + let report = classify_compat_change(&old, &new).unwrap(); + assert!(report.breaking.is_empty(), "{report:?}"); + assert_eq!( + report.additive, + vec![ + "added host_adapter.optional_kernel_exports entry \"kernel_get_process_exit_signal\"", + ], + ); + } + + #[test] + fn changing_required_host_adapter_export_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["host_adapter"]["required_kernel_exports"] = + json!(["__abi_version", "kernel_new_requirement",]); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed host_adapter field \"required_kernel_exports\""], + ); + } + #[test] fn changed_existing_export_is_breaking() { let old = base_snapshot(); @@ -2182,6 +2566,19 @@ mod tests { ); } + #[test] + fn making_existing_syscall_pointer_required_is_breaking() { + let old = base_snapshot(); + let mut new = old.clone(); + new["syscall_arg_descriptors"]["1"][0]["required"] = json!(true); + + let report = classify_compat_change(&old, &new).unwrap(); + assert_eq!( + report.breaking, + vec!["changed syscall_arg_descriptors entry \"1\""] + ); + } + #[test] fn changed_channel_layout_is_breaking() { let old = base_snapshot(); diff --git a/tools/xtask/src/pkg_manifest.rs b/tools/xtask/src/pkg_manifest.rs index af12640d83..fa0e1bc80f 100644 --- a/tools/xtask/src/pkg_manifest.rs +++ b/tools/xtask/src/pkg_manifest.rs @@ -180,6 +180,25 @@ pub struct ProgramOutput { pub fork_instrumentation: ForkInstrumentationPolicy, } +/// A non-Wasm file that a program package needs at runtime. +/// +/// `artifact` is produced in the package cache/archive; `guest_path` is where +/// VFS consumers install those bytes. Runtime files are mirrored separately +/// from `[[outputs]]` so the latter remains truthful about executable/archive +/// program artifacts and its legacy output-count layout stays unchanged. +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct RuntimeFile { + pub artifact: String, + pub guest_path: String, + #[serde(default = "default_runtime_file_mode")] + pub mode: u32, +} + +fn default_runtime_file_mode() -> u32 { + 0o644 +} + /// One entry in a manifest's `[[host_tools]]` array. Inline /// declaration on the consumer site — no separate registry entry, /// per design 10. @@ -414,6 +433,10 @@ pub struct DepsManifest { /// `validate_outputs` in the resolver (wired in Chunk B Task B.2). pub program_outputs: Vec, + /// Non-Wasm runtime closure declared by `kind = "program"` manifests. + /// Empty for library/source manifests. + pub runtime_files: Vec, + /// Build-time provenance + ABI compatibility. Always `None` for /// manifests parsed via [`DepsManifest::parse`] (source `package.toml`) /// and always `Some` for those parsed via @@ -699,6 +722,98 @@ fn validate_build_input_path(path: &str) -> Result<(), String> { Ok(()) } +/// Validate an artifact path before it can be joined to a build/cache root. +/// +/// This is shared by library and program outputs so neither manifest shape can +/// escape its package directory through an absolute path or `..` component. +fn validate_output_path(path: &str, context: &str) -> Result<(), String> { + if path.is_empty() { + return Err(format!("{context} must not be empty")); + } + if path.contains('\0') || path.contains('\\') { + return Err(format!( + "{context} must be a portable '/'-separated path, got {path:?}" + )); + } + if path.starts_with('/') || Path::new(path).is_absolute() { + return Err(format!( + "{context} must be relative to the package output directory, got {path:?}" + )); + } + for component in path.split('/') { + if component.is_empty() { + return Err(format!( + "{context} must not contain empty path components, got {path:?}" + )); + } + if component == "." { + return Err(format!("{context} must not contain '.', got {path:?}")); + } + if component == ".." { + return Err(format!("{context} must not contain '..', got {path:?}")); + } + } + Ok(()) +} + +fn validate_single_path_component(value: &str, context: &str) -> Result<(), String> { + if value.is_empty() { + return Err(format!("{context} must not be empty")); + } + if value == "." + || value == ".." + || value.contains('/') + || value.contains('\\') + || value.contains('\0') + { + return Err(format!( + "{context} must be a safe single path component, got {value:?}" + )); + } + Ok(()) +} + +/// Both paths describe files. Equal paths and ancestor/descendant pairs are +/// therefore unsatisfiable in one artifact or VFS closure. +fn file_paths_conflict(a: &str, b: &str) -> bool { + a == b + || a.strip_prefix(b) + .is_some_and(|suffix| suffix.starts_with('/')) + || b.strip_prefix(a) + .is_some_and(|suffix| suffix.starts_with('/')) +} + +fn validate_runtime_guest_path(path: &str, context: &str) -> Result<(), String> { + if path.contains('\0') || path.contains('\\') { + return Err(format!( + "{context} must be a normalized absolute POSIX path, got {path:?}" + )); + } + if !path.starts_with('/') || path == "/" { + return Err(format!( + "{context} must be a non-root absolute POSIX path, got {path:?}" + )); + } + if path.ends_with('/') { + return Err(format!( + "{context} must not have a trailing '/', got {path:?}" + )); + } + for component in path[1..].split('/') { + if component.is_empty() { + return Err(format!( + "{context} must not contain empty path components, got {path:?}" + )); + } + if component == "." || component == ".." { + return Err(format!( + "{context} must not contain {component:?}, got {path:?}" + )); + } + } + Ok(()) +} + #[derive(Debug, Clone, Deserialize, Default)] pub struct Outputs { #[serde(default)] @@ -707,6 +822,10 @@ pub struct Outputs { pub headers: Vec, #[serde(default)] pub pkgconfig: Vec, + /// Runtime data or other package files that are neither link libraries, + /// headers, nor pkg-config metadata. + #[serde(default)] + pub files: Vec, } /// `name@version` reference, parsed from `depends_on` strings. @@ -735,6 +854,14 @@ impl DepRef { if name.contains('@') { return Err(format!("dep name {:?} must not contain '@'", name)); } + // Registry lookup joins the name directly below each registry root; + // cache/spec logic also treats the exact version as an identity token. + // Reject traversal and nested-path spellings before either value can + // reach filesystem interpolation. + validate_single_path_component(name, "dep name") + .map_err(|e| format!("dep reference {s:?}: {e}"))?; + validate_single_path_component(version, "dep version") + .map_err(|e| format!("dep reference {s:?}: {e}"))?; Ok(Self { name: name.into(), version: version.into(), @@ -752,6 +879,7 @@ impl std::fmt::Display for DepRef { /// validated [`DepsManifest`] so normalization (default build script, /// parsed DepRefs, etc.) lives in one place. #[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] struct Raw { kind: ManifestKind, name: String, @@ -781,6 +909,8 @@ struct Raw { #[serde(default = "default_outputs_value")] outputs: toml::Value, #[serde(default)] + runtime_files: Vec, + #[serde(default)] compatibility: Option, // `binary` accepts two shapes: // * bare `[binary]` (archive_url + archive_sha256 directly), @@ -812,7 +942,63 @@ fn default_outputs_value() -> toml::Value { toml::Value::Table(toml::value::Table::new()) } +fn program_output_dest_rel( + package_name: &str, + output_count: usize, + out: &ProgramOutput, +) -> PathBuf { + let basename = Path::new(&out.wasm) + .file_name() + .and_then(|s| s.to_str()) + .unwrap_or(&out.wasm); + let ext = match basename.find('.') { + Some(i) => &basename[i..], + None => "", + }; + let dest_name = format!("{}{}", out.name, ext); + if output_count > 1 { + Path::new(package_name).join(dest_name) + } else { + PathBuf::from(dest_name) + } +} + impl DepsManifest { + /// Resolver mirror path under `programs//` for a runtime file. + /// Runtime files always live below the package name, independently of the + /// number of executable `[[outputs]]` entries. + pub fn runtime_file_dest_rel_for(&self, runtime_file: &RuntimeFile) -> PathBuf { + Path::new(&self.name).join(&runtime_file.artifact) + } + + /// Runtime-file mirror path keyed by the exact declared artifact path. + /// Exact lookup keeps nested runtime closures representable and avoids + /// basename ambiguity (`a/data` versus `b/data`). + pub fn runtime_file_dest_rel(&self, artifact: &str) -> Result { + if self.kind != ManifestKind::Program { + return Err(format!( + "manifest {:?} is kind={:?}; runtime-file lookup is program-only", + self.name, self.kind + )); + } + let matches: Vec<&RuntimeFile> = self + .runtime_files + .iter() + .filter(|runtime_file| runtime_file.artifact == artifact) + .collect(); + match matches.as_slice() { + [runtime_file] => Ok(self.runtime_file_dest_rel_for(runtime_file)), + [] => Err(format!( + "program {:?} has no [[runtime_files]] artifact {:?}", + self.name, artifact + )), + _ => Err(format!( + "program {:?} has multiple [[runtime_files]] artifacts {:?}", + self.name, artifact + )), + } + } + /// Relative path under `programs//` where `out` should land /// once produced. Single source of truth for the placement /// convention; both `place_binaries_symlinks` (resolver-driven @@ -828,20 +1014,7 @@ impl DepsManifest { /// `` is everything from the first `.` onward in `out.wasm`'s /// basename, so `.vfs.zst`, `.tar.gz`, etc. round-trip intact. pub fn output_dest_rel_for(&self, out: &ProgramOutput) -> PathBuf { - let basename = Path::new(&out.wasm) - .file_name() - .and_then(|s| s.to_str()) - .unwrap_or(&out.wasm); - let ext = match basename.find('.') { - Some(i) => &basename[i..], - None => "", - }; - let dest_name = format!("{}{}", out.name, ext); - if self.program_outputs.len() > 1 { - Path::new(&self.name).join(dest_name) - } else { - PathBuf::from(dest_name) - } + program_output_dest_rel(&self.name, self.program_outputs.len(), out) } /// Same as [`output_dest_rel_for`] but keyed by the `wasm` @@ -1187,12 +1360,18 @@ impl DepsManifest { if raw.name.is_empty() { return Err("name must not be empty".into()); } + validate_single_path_component(&raw.name, "name")?; if raw.name.contains('@') { return Err(format!("name {:?} must not contain '@'", raw.name)); } if raw.version.is_empty() { return Err("version must not be empty".into()); } + // `version` is interpolated into canonical cache directories and + // archive filenames. Keep it to one portable filesystem component so + // a manifest cannot escape those roots or create an ambiguous nested + // path before any resolver/archive code sees it. + validate_single_path_component(&raw.version, "version")?; // revision: archived → declared (validate_archived rejects None // up-front); source → defaults to 1 since source manifests no // longer carry it. Downstream code reads m.revision; the @@ -1330,8 +1509,59 @@ impl DepsManifest { }); } + let runtime_files = raw.runtime_files; + if !matches!(raw.kind, ManifestKind::Program) && !runtime_files.is_empty() { + return Err("[[runtime_files]] is allowed only for kind = \"program\"".into()); + } + let mut runtime_artifacts = BTreeSet::new(); + let mut runtime_guest_paths = BTreeSet::new(); + for (idx, runtime_file) in runtime_files.iter().enumerate() { + validate_output_path( + &runtime_file.artifact, + &format!("[[runtime_files]][{idx}].artifact"), + )?; + validate_runtime_guest_path( + &runtime_file.guest_path, + &format!("[[runtime_files]][{idx}].guest_path"), + )?; + if runtime_file.mode > 0o777 { + return Err(format!( + "[[runtime_files]][{idx}].mode must be between 0 and 0777, got {}", + runtime_file.mode + )); + } + if !runtime_artifacts.insert(runtime_file.artifact.clone()) { + return Err(format!( + "[[runtime_files]] declares artifact {:?} more than once", + runtime_file.artifact + )); + } + if !runtime_guest_paths.insert(runtime_file.guest_path.clone()) { + return Err(format!( + "[[runtime_files]] declares guest_path {:?} more than once", + runtime_file.guest_path + )); + } + } + for (index, left) in runtime_files.iter().enumerate() { + for right in runtime_files.iter().skip(index + 1) { + if file_paths_conflict(&left.artifact, &right.artifact) { + return Err(format!( + "[[runtime_files]] artifact paths {:?} and {:?} conflict as file/ancestor paths", + left.artifact, right.artifact + )); + } + if file_paths_conflict(&left.guest_path, &right.guest_path) { + return Err(format!( + "[[runtime_files]] guest paths {:?} and {:?} conflict as file/ancestor paths", + left.guest_path, right.guest_path + )); + } + } + } + // Dispatch on `kind` to decide whether `outputs` is the - // library shape (`[outputs]` table with libs/headers/pkgconfig) + // library shape (`[outputs]` table with libs/headers/pkgconfig/files) // or the program shape (`[[outputs]]` array-of-tables with // name/wasm). A mismatch between the two is rejected at parse // time: each kind enforces its own grammar. @@ -1346,6 +1576,16 @@ impl DepsManifest { .outputs .try_into() .map_err(|e| format!("parse [outputs] table: {e}"))?; + for (field, paths) in [ + ("libs", &outputs.libs), + ("headers", &outputs.headers), + ("pkgconfig", &outputs.pkgconfig), + ("files", &outputs.files), + ] { + for (idx, path) in paths.iter().enumerate() { + validate_output_path(path, &format!("[outputs].{field}[{idx}]"))?; + } + } (outputs, Vec::new()) } ManifestKind::Program => { @@ -1375,12 +1615,11 @@ impl DepsManifest { ); } for (idx, out) in program_outputs.iter().enumerate() { - if out.name.is_empty() { - return Err(format!("[[outputs]][{idx}].name must not be empty")); - } + validate_single_path_component(&out.name, &format!("[[outputs]][{idx}].name"))?; if out.wasm.is_empty() { return Err(format!("[[outputs]][{idx}].wasm must not be empty")); } + validate_output_path(&out.wasm, &format!("[[outputs]][{idx}].wasm"))?; } (Outputs::default(), program_outputs) } @@ -1407,6 +1646,56 @@ impl DepsManifest { } }; + if matches!(raw.kind, ManifestKind::Program) { + let mut output_artifacts: Vec<&str> = Vec::new(); + let mut output_mirrors: Vec = Vec::new(); + for (idx, out) in program_outputs.iter().enumerate() { + for (prior_idx, prior_artifact) in output_artifacts.iter().enumerate() { + if file_paths_conflict(prior_artifact, &out.wasm) { + return Err(format!( + "[[outputs]][{prior_idx}].wasm {:?} conflicts with [[outputs]][{idx}].wasm {:?}", + prior_artifact, out.wasm + )); + } + } + let mirror = program_output_dest_rel(&raw.name, program_outputs.len(), out); + for prior in &output_mirrors { + if file_paths_conflict(&prior.to_string_lossy(), &mirror.to_string_lossy()) { + return Err(format!( + "program outputs collide in the resolver mirror at {} and {}", + prior.display(), + mirror.display() + )); + } + } + output_artifacts.push(&out.wasm); + output_mirrors.push(mirror); + } + for (idx, runtime_file) in runtime_files.iter().enumerate() { + for out in &program_outputs { + if file_paths_conflict(&out.wasm, &runtime_file.artifact) { + return Err(format!( + "[[runtime_files]][{idx}].artifact {:?} conflicts with [[outputs]].wasm {:?}", + runtime_file.artifact, out.wasm + )); + } + } + let mirror = Path::new(&raw.name).join(&runtime_file.artifact); + for output_mirror in &output_mirrors { + if file_paths_conflict( + &output_mirror.to_string_lossy(), + &mirror.to_string_lossy(), + ) { + return Err(format!( + "[[runtime_files]][{idx}].artifact {:?} collides with a program output mirror at {}", + runtime_file.artifact, + output_mirror.display() + )); + } + } + } + } + // Default `arches` to `["wasm32"]` when omitted. Reject // duplicates so a manifest can't say `["wasm32", "wasm32"]`. let target_arches = if raw.arches.is_empty() { @@ -1432,6 +1721,7 @@ impl DepsManifest { build: raw.build, outputs, program_outputs, + runtime_files, compatibility: raw.compatibility, binary, host_tools, @@ -1587,6 +1877,7 @@ cache_key_sha = "111111111111111111111111111111111111111111111111111111111111111 assert_eq!(m.revision, 1); assert!(m.depends_on.is_empty()); assert_eq!(m.outputs.libs, vec!["lib/libz.a"]); + assert!(m.outputs.files.is_empty()); assert_eq!(m.spec(), "zlib@1.3.1"); // Fallback (no [build].script_path) resolves against self.dir, // so the repo_root argument is irrelevant. @@ -1596,6 +1887,105 @@ cache_key_sha = "111111111111111111111111111111111111111111111111111111111111111 ); } + #[test] + fn source_and_archived_manifests_reject_unsafe_version_path_components() { + for version in [".", "..", "../escape", "nested/version", r"nested\version"] { + for (label, fixture, archived) in [ + ("source", EXAMPLE, false), + ("archived", EXAMPLE_ARCHIVED, true), + ] { + let text = fixture.replace( + "version = \"1.3.1\"", + &format!("version = {version:?}"), + ); + let result = if archived { + DepsManifest::parse_archived(&text, PathBuf::from("/x")) + } else { + DepsManifest::parse(&text, PathBuf::from("/x")) + }; + let err = result.expect_err("unsafe version must fail before path interpolation"); + assert!( + err.contains("version") && err.contains("safe single path component"), + "{label} version {version:?}: got {err}" + ); + } + } + } + + #[test] + fn source_and_archived_manifests_reject_unknown_top_level_fields() { + for (label, fixture, archived) in [ + ("source", EXAMPLE, false), + ("archived", EXAMPLE_ARCHIVED, true), + ] { + let text = fixture.replace( + "depends_on = []", + "depends_on = []\nfuture_recipe_field = true", + ); + let result = if archived { + DepsManifest::parse_archived(&text, PathBuf::from("/x")) + } else { + DepsManifest::parse(&text, PathBuf::from("/x")) + }; + let err = result.expect_err("unknown top-level field must fail loudly"); + assert!( + err.contains("future_recipe_field") && err.contains("unknown field"), + "{label}: got {err}" + ); + } + } + + #[test] + fn parses_library_runtime_file_outputs() { + let text = EXAMPLE.replace( + "headers = [\"include/zlib.h\"]", + "headers = [\"include/zlib.h\"]\nfiles = [\"share/zlib/runtime.dat\"]", + ); + let m = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap(); + assert_eq!(m.outputs.files, vec!["share/zlib/runtime.dat"]); + } + + #[test] + fn rejects_library_output_paths_outside_the_package_root() { + for (path, expected) in [ + ("/tmp/runtime.dat", "must be relative"), + ("share/../../runtime.dat", "must not contain '..'"), + ] { + let text = EXAMPLE.replace( + "headers = [\"include/zlib.h\"]", + &format!("headers = [\"include/zlib.h\"]\nfiles = [{path:?}]"), + ); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("[outputs].files[0]"), "got: {err}"); + assert!(err.contains(expected), "got: {err}"); + } + } + + #[test] + fn rejects_program_output_paths_outside_the_package_root() { + let text = r#" +kind = "program" +name = "escape" +version = "1.0.0" +kernel_abi = 8 +depends_on = [] + +[source] +url = "https://example.test/escape.tar.gz" +sha256 = "0000000000000000000000000000000000000000000000000000000000000000" + +[license] +spdx = "MIT" + +[[outputs]] +name = "escape" +wasm = "../escape.wasm" +"#; + let err = DepsManifest::parse(text, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("[[outputs]][0].wasm"), "got: {err}"); + assert!(err.contains("must not contain '..'"), "got: {err}"); + } + #[test] fn build_script_override_is_repo_root_relative() { // Phase A-bis Task 2: an explicit `[build].script_path` is @@ -1864,6 +2254,36 @@ spdx = "TestLicense" assert!(DepRef::parse("zlib@").is_err()); } + #[test] + fn depref_rejects_registry_and_version_path_traversal() { + for reference in [ + "../escape@1.0", + "nested/package@1.0", + r"nested\package@1.0", + "zlib@../escape", + "zlib@nested/version", + r"zlib@nested\version", + ".@1.0", + "zlib@..", + ] { + let err = DepRef::parse(reference).unwrap_err(); + assert!( + err.contains("safe single path component"), + "{reference:?}: got {err}" + ); + } + + let text = EXAMPLE.replace( + "depends_on = []", + r#"depends_on = ["../outside@1.0"]"#, + ); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("dep name") && err.contains("safe single path component"), + "got: {err}" + ); + } + #[test] fn depends_on_parsed_into_deprefs() { let text = EXAMPLE.replace( @@ -2113,6 +2533,194 @@ wasm = "vim.wasm" assert!(m.outputs.libs.is_empty()); assert!(m.outputs.headers.is_empty()); assert!(m.outputs.pkgconfig.is_empty()); + assert!(m.outputs.files.is_empty()); + assert!(m.runtime_files.is_empty()); + } + + #[test] + fn rejects_singular_runtime_file_table_typo() { + let text = format!( + "{PROGRAM_EXAMPLE}\n[[runtime_file]]\nartifact = \"icu.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"\n" + ); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("runtime_file") && err.contains("unknown field"), + "got: {err}" + ); + } + + fn program_with_runtime_file(artifact: &str, guest_path: &str, mode: Option) -> String { + format!( + "{PROGRAM_EXAMPLE}\n[[runtime_files]]\nartifact = {artifact:?}\nguest_path = {guest_path:?}\n{}", + mode.map(|value| format!("mode = {value}\n")).unwrap_or_default(), + ) + } + + #[test] + fn parses_program_runtime_file_and_stable_mirror_path() { + let text = program_with_runtime_file("icu.dat", "/usr/lib/php/icu.dat", None); + let m = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap(); + assert_eq!( + m.runtime_files, + vec![RuntimeFile { + artifact: "icu.dat".into(), + guest_path: "/usr/lib/php/icu.dat".into(), + mode: 0o644, + }] + ); + assert_eq!( + m.runtime_file_dest_rel("icu.dat").unwrap(), + PathBuf::from("vim/icu.dat") + ); + // Runtime-file placement must not perturb the legacy single-output + // destination convention. + assert_eq!( + m.output_dest_rel("vim.wasm").unwrap(), + PathBuf::from("vim.wasm") + ); + + let nested = program_with_runtime_file("share/icu/icu.dat", "/usr/lib/php/icu.dat", None); + let nested = DepsManifest::parse(&nested, PathBuf::from("/x")).unwrap(); + assert_eq!( + nested.runtime_file_dest_rel("share/icu/icu.dat").unwrap(), + PathBuf::from("vim/share/icu/icu.dat") + ); + assert!(nested.runtime_file_dest_rel("icu.dat").is_err()); + } + + #[test] + fn rejects_unsafe_runtime_artifact_paths() { + for (artifact, expected) in [ + ("", "must not be empty"), + ("/icu.dat", "must be relative"), + ("../icu.dat", "must not contain '..'"), + ("./icu.dat", "must not contain '.'"), + ("share//icu.dat", "empty path components"), + ("share/icu.dat/", "empty path components"), + (r"share\icu.dat", "portable"), + ] { + let text = program_with_runtime_file(artifact, "/usr/lib/php/icu.dat", None); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("[[runtime_files]][0].artifact"), "got: {err}"); + assert!(err.contains(expected), "got: {err}"); + } + } + + #[test] + fn rejects_unsafe_runtime_guest_paths() { + for (guest_path, expected) in [ + ("icu.dat", "non-root absolute"), + ("/", "non-root absolute"), + ("/usr/lib/php/", "trailing"), + ("/usr//php/icu.dat", "empty path components"), + ("/usr/./icu.dat", "must not contain"), + ("/usr/../icu.dat", "must not contain"), + (r"/usr/lib\icu.dat", "normalized absolute POSIX"), + ] { + let text = program_with_runtime_file("icu.dat", guest_path, None); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("[[runtime_files]][0].guest_path"), + "got: {err}" + ); + assert!(err.contains(expected), "got: {err}"); + } + } + + #[test] + fn rejects_runtime_file_on_non_program_and_invalid_mode() { + let library = format!( + "{EXAMPLE}\n[[runtime_files]]\nartifact = \"icu.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"\n" + ); + let err = DepsManifest::parse(&library, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("program"), "got: {err}"); + + let text = program_with_runtime_file("icu.dat", "/usr/lib/php/icu.dat", Some(0o1000)); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("0777"), "got: {err}"); + + let unknown = format!( + "{PROGRAM_EXAMPLE}\n[[runtime_files]]\nartifact = \"icu.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"\nmod = 384\n" + ); + let err = DepsManifest::parse(&unknown, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("unknown field") && err.contains("mod"), + "got: {err}" + ); + } + + #[test] + fn rejects_duplicate_and_colliding_runtime_files() { + let duplicate = format!( + "{}\n[[runtime_files]]\nartifact = \"icu.dat\"\nguest_path = \"/other.dat\"\n", + program_with_runtime_file("icu.dat", "/usr/lib/php/icu.dat", None), + ); + let err = DepsManifest::parse(&duplicate, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("artifact") && err.contains("more than once"), + "got: {err}" + ); + + let duplicate_guest = format!( + "{}\n[[runtime_files]]\nartifact = \"other.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"\n", + program_with_runtime_file("icu.dat", "/usr/lib/php/icu.dat", None), + ); + let err = DepsManifest::parse(&duplicate_guest, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("guest_path") && err.contains("more than once"), + "got: {err}" + ); + + let artifact_collision = PROGRAM_EXAMPLE.replace( + "wasm = \"vim.wasm\"", + "wasm = \"vim.wasm\"\n\n[[runtime_files]]\nartifact = \"vim.wasm\"\nguest_path = \"/usr/lib/vim.wasm\"", + ); + let err = DepsManifest::parse(&artifact_collision, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("conflict"), "got: {err}"); + + let prefix = format!( + "{}\n[[runtime_files]]\nartifact = \"share/icu.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"\n", + program_with_runtime_file("share", "/usr/lib/php", None), + ); + let err = DepsManifest::parse(&prefix, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("ancestor"), "got: {err}"); + + let output_prefix = PROGRAM_EXAMPLE.replace( + "wasm = \"vim.wasm\"", + "wasm = \"share\"\n\n[[runtime_files]]\nartifact = \"share/icu.dat\"\nguest_path = \"/usr/lib/php/icu.dat\"", + ); + let err = DepsManifest::parse(&output_prefix, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("conflicts with [[outputs]].wasm"), + "got: {err}" + ); + } + + #[test] + fn rejects_unsafe_names_and_duplicate_program_mirrors() { + for package_name in ["../escape", "/absolute", "a/b", r"a\b"] { + let text = + PROGRAM_EXAMPLE.replacen("name = \"vim\"", &format!("name = {package_name:?}"), 1); + let err = DepsManifest::parse(&text, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("safe single path component"), "got: {err}"); + } + + let unsafe_output = PROGRAM_EXAMPLE.replace( + "[[outputs]]\nname = \"vim\"", + "[[outputs]]\nname = \"../vim\"", + ); + let err = DepsManifest::parse(&unsafe_output, PathBuf::from("/x")).unwrap_err(); + assert!(err.contains("[[outputs]][0].name"), "got: {err}"); + + let duplicate_mirror = PROGRAM_EXAMPLE.replace( + "[[outputs]]\nname = \"vim\"\nwasm = \"vim.wasm\"", + "[[outputs]]\nname = \"same\"\nwasm = \"a.wasm\"\n\n[[outputs]]\nname = \"same\"\nwasm = \"b.wasm\"", + ); + let err = DepsManifest::parse(&duplicate_mirror, PathBuf::from("/x")).unwrap_err(); + assert!( + err.contains("collide") && err.contains("same.wasm"), + "got: {err}" + ); } #[test]