Skip to content

Run every wasi-testsuite case on Linux, macOS and Windows - #499

Draft
kateinoigakukun wants to merge 15 commits into
mainfrom
wasi-testsuite-compliance
Draft

kateinoigakukun wants to merge 15 commits into
mainfrom
wasi-testsuite-compliance

Conversation

@kateinoigakukun

@kateinoigakukun kateinoigakukun commented Oct 5, 2026 •

Copy link
Copy Markdown
Member

Every case of the newest wasi-testsuite (72 wasm32-wasip1 programs) now runs with no skip list on Linux, macOS and Windows. Before this PR, the suite was pinned to July 2025.

Linux macOS Windows
Skipped cases before 11 10 53
Skipped cases after 0 0 0

Existing bugs fixed first

Area Problem Fix
Test: fdReaddirDoesNotLeakFileDescriptors Lowered the process-wide RLIMIT_NOFILE while other tests ran in parallel, so they failed with EMFILE at random Compare descriptor counts instead
poll_oneoff on macOS /dev/null is reported POLLNVAL, so polling stdin answered BADF Treat an open POLLNVAL descriptor as ready, as select does
path_filestat_get + SYMLINK_FOLLOW (sandbox leak) The host followed the final symlink, so link -> ../../etc/passwd exposed host metadata Resolve the final symlink inside the sandbox
fd_readdir Stat'ed every entry, including the ones skipped to reach the cookie: O(n²) Take the inode from readdir, as wasmtime does

Test runner

The runner follows the upstream doc/specification.md:

Upstream change What the runner does
Binaries moved to testsuite/wasm32-wasip1/ Discovers the new layout; pins e0aa527f, the newest prod/testsuite-base
root (preopened as /) replaced dirs Preopens root as /
stdout and stderr are checked Gives the guest files for stdio, compares them, and shows stderr when a guest traps
Upstream runs cases one at a time Runs serially, since cases share names like file.cleanup (about 2 s in total)

Linux / macOS fixes

Test unskipped Fix
dir_fd_op_failures fd_allocate on a directory returns BADF
path_filestat path_open passes SYNC, DSYNC, RSYNC and NONBLOCK on to the host; O_SYNC is decoded correctly on glibc
symlink_filestat path_filestat_set_times uses utimensat(AT_SYMLINK_NOFOLLOW), following inside the sandbox
path_link path_link is implemented with linkat; SYMLINK_FOLLOW returns INVAL, as in wasmtime
symlink_create An absolute symlink target is refused with PERM

Windows

New file: Sources/WASI/Platform/PAL/PALWindows.swift. No platform-layer API changes beyond one closure parameter, and no Foundation.

Topic Approach
Directories Open them with backup semantics and wrap the HANDLE in a CRT fd, so FileDescriptor is unchanged
*at operations Use NtCreateFile / NtSetInformationFile relative to the directory HANDLE (looked up from ntdll at run time). No path is ever rebuilt, so concurrent renames cannot redirect an operation
Symlinks Never followed by this layer; the sandbox walker resolves them. Targets containing \ or : are refused. Created relative to the handle; without the symlink privilege, falls back to CreateSymbolicLinkW, as cap-std does
POSIX semantics EISDIR for O_CREAT/O_TRUNC on a directory; a hard link to a symlink links the symlink; POSIX-semantics delete and rename; . cannot be renamed; case-only renames work; only symlinks and junctions count as symlinks

Follow-up commits, one per test:

Test unskipped Fix
pwrite-with-append Emulated pread/pwrite restore the file offset
fd_flags_set, path_filestat Open options are kept per fd, so APPEND can be reported and toggled
stdio NUL and the console are stat'ed as character devices
poll_oneoff_stdio poll_oneoff probes handles by kind (file, pipe, console)

Not included

Item Note
wasm32-wasip3 suites Need the component model
Operation-based test specs No wasip1 test uses them yet
Windows poll latency Polls every 1 ms; can notice readiness up to about 16 ms late
Windows pread/pwrite Three seeks where OVERLAPPED I/O would need one
Atomic append on Windows Seek-then-write, like the CRT's own _O_APPEND

fdReaddirDoesNotLeakFileDescriptors lowered RLIMIT_NOFILE to 32 above
the descriptors open at the time. The limit is process-wide, so any
test opening files in parallel could fail with EMFILE while the loop
ran, and fileDescriptorLeakTest and the wasi-testsuite cases sometimes
did. Count the open descriptors before and after the loop instead; a
leak adds one per call, far more than parallel tests hold at once.
Darwin's poll supports few devices besides ttys, and flags the others,
/dev/null among them, POLLNVAL even though they are open. poll_oneoff
passed that on as BADF, so a guest polling stdin failed when it was
redirected from /dev/null, as poll_oneoff_stdio does under a test
runner. select(2) reports such a device as always ready, so report it
ready too when the descriptor is still open.
With SYMLINK_FOLLOW, path_filestat_get resolved all but the last
component in the sandbox and then let the host's fstatat follow a
symlink there. A symlink pointing out of the preopen, such as
`../../etc/passwd` or `/`, therefore reported the size, times and
inode of a host file outside it.

Resolve the final symlink in the sandbox too: while the last component
names a symlink, read it and resolve its target relative to the same
preopen, refusing absolute targets and stopping after as many links as
path_open allows. The host then only ever stats something that is not
a symlink.
fd_readdir stat'ed every entry it returned for its inode, and stat'ed
the entries it skipped to reach the cookie too, then threw those
results away. Listing a directory of n entries in chunks therefore
cost O(n^2) fstatat calls, since each call starts over from the first
entry. Use the inode readdir already reports, as wasmtime does, and
skip entries without building them.
The pinned revision was from July 2025. Upstream has since moved each
suite's binaries under a per-WASI-version directory, preopens a test's
`root` as the guest's `/` instead of listing `dirs`, and checks stdout
and stderr. Point at the newest prebuilt binaries and follow the
current specification: run the wasm32-wasip1 suites, preopen the root,
and give the guest files for stdio so that its output can be checked
and none of them is a tty, as under the upstream runner. A trap now
reports what the guest printed to stderr, which is where a Rust test
explains its panic. Cases run one at a time, as under the upstream runner,
since several create the same names in the shared root.

Of the eleven tests skipped on Linux and macOS, six pass against the
new binaries. The other five stay skipped until the following commits
fix them. fd_advise no longer needs NO_FD_ALLOCATE, since the test now
accepts NOTSUP from fd_allocate.
fd_allocate answered NOTSUP for any open descriptor, including a
directory, where dir_fd_op_failures expects ISDIR, BADF or NOTCAPABLE
like every other file-only operation. Check that the descriptor is a
file first, and answer BADF otherwise as wasmtime does.
path_open passed only APPEND of the requested fdflags on to the host, so
a file opened with SYNC, DSYNC, RSYNC or NONBLOCK did not get them, and
fd_fdstat_get did not report them back, which path_filestat checks.
Pass them all on.

Reading the flags back also tested O_SYNC and O_RSYNC for any of their
bits. Glibc defines both to include O_DSYNC, so a DSYNC file was
reported as SYNC too. Require every bit instead.
path_filestat_set_times opened its target to call futimens, with
O_NOFOLLOW when the guest did not ask to follow symlinks. Opening a
symlink that way fails with ELOOP, so the times of a symlink itself
could not be set, which symlink_filestat does. Call utimensat with
AT_SYMLINK_NOFOLLOW on the last component instead, as wasmtime does;
openParent still resolves the rest of the path inside the sandbox.

When following, resolve the final symlink inside the sandbox as
path_filestat_get does, and set the times of what it names the same
way. Opening the target is no longer needed at all: it opened for
writing, which failed on every directory, and it blocked on a FIFO.
path_link answered NOTSUP. Create the link with linkat on the parents
that openParent resolves inside the sandbox, without AT_SYMLINK_FOLLOW,
so that a symlink is linked rather than its target. A guest asking to
follow is refused with INVAL, as in wasmtime, since the host would
otherwise resolve the symlink outside the sandbox.

WASIDir gains a link requirement. Its default answers NOTSUP, so the
memory file system and SPI implementations outside this package keep
building.
path_symlink created a symlink to an absolute path, which sandboxed
resolution then refuses to follow anyway, and symlink_create expects
the creation itself to fail. Answer PERM up front, as wasmtime does.

This was the last wasi-testsuite case skipped on Linux and macOS.
On Windows a guest could not open a directory: preopens went through
_wsopen_s, which refuses directories, so a preopen failed with EACCES,
and every directory-relative primitive of the platform layer (openat,
fstatat, readlinkat, mkdirat, unlinkat, renameat, linkat, symlinkat,
utimensat, readdir) answered NOTSUP. Most of the 53 wasi-testsuite
cases skipped on Windows failed for this; 48 of them pass now.

Open directories with backup semantics and wrap the HANDLE in a CRT
descriptor, so FileDescriptor stays a CRT descriptor everywhere above
the platform layer. Win32 has no openat, but the NT layer does:
NtCreateFile opens a name relative to a directory HANDLE, and
NtSetInformationFile renames or links an open file into one. Every
primitive goes through those, never through a path rebuilt from the
HANDLE, so a guest renaming directories or planting symlinks from
another thread cannot redirect it. The primitives never follow a
symlink: the sandbox walker resolves those itself, and an open that
lands on one fails with ELOOP, as with O_NOFOLLOW. A name with a
backslash or a colon, which Windows would read as a separator or a
stream, is refused, and so is a symlink target with either.

They give POSIX outcomes where Windows differs:
- O_CREAT or O_TRUNC on a directory fails with EISDIR, and an existing
  symlink is never truncated.
- A hard link to a symlink links the symlink.
- Deleting and renaming use POSIX semantics, so a name disappears at
  once even while open, and renaming onto an empty directory replaces
  it.
- `.` cannot be renamed, so a preopen cannot be moved through itself.
- Only symlinks and junctions count as symlinks; other reparse points,
  such as cloud-file placeholders, are ordinary files.

Windows records whether a symlink points to a directory, so the
symlinkat primitive takes a closure that looks the target up in the
sandbox. Creating a symlink by handle needs the symlink privilege; a
non-elevated process falls back to CreateSymbolicLinkW on the
directory's path, as cap-std does for wasmtime.
The CRT offers neither pread nor pwrite, so fd_pread and fd_pwrite
seeked to the requested offset and left the descriptor there, while
POSIX leaves the offset alone. pwrite-with-append checks that. Seek
back to where the descriptor was afterwards.
The CRT has no F_GETFL or F_SETFL, so fd_fdstat_get reported no flags
on Windows, where path_filestat expects the APPEND it opened with, and
fd_fdstat_set_flags was ignored, so fd_flags_set could not turn APPEND
off. Record the open options per descriptor in the platform layer
instead, update them on F_SETFL as Linux does (append and nonblocking
only), and seek to the end before each write while append is set, as
the CRT's own _O_APPEND did. A pipe or console cannot seek but appends
anyway, so a failed seek is ignored.
GetFileInformationByHandle fails on character devices such as NUL and
the console, so fd_fdstat_get and fd_filestat_get failed on stdio
redirected from NUL, which the stdio test does after renumbering it.
Report such a handle as a character device with no other metadata
instead, as fstat would.
poll_oneoff answered NOTSUP for any descriptor subscription on Windows,
which has no poll for arbitrary handles, so poll_oneoff_stdio failed.
Probe each handle by its kind instead, until one is ready or the clock
runs out: disk files and devices such as NUL are always ready, as
poll(2) reports regular files; a pipe is readable once it holds data
or its writer is gone; console input once it has pending events;
anything is writable.

This was the last wasi-testsuite case skipped on any platform, so the
skip lists go away.
@kateinoigakukun
kateinoigakukun force-pushed the wasi-testsuite-compliance branch 2 times, most recently from b69f6fb to d8d3df0 Compare October 5, 2026 14:43

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant