From 48f68a788e87420872f98e831e005cbf43d6fb0b Mon Sep 17 00:00:00 2001 From: Deano Calver Date: Fri, 13 Mar 2026 14:29:31 +0200 Subject: [PATCH 1/5] Add first-class Windows support --- .github/workflows/ci.yml | 125 ++-- .github/workflows/release.yml | 40 +- README.md | 34 +- build.zig | 1 + install.ps1 | 43 ++ src/cache.zig | 4 +- src/cli.zig | 9 +- src/guard.zig | 83 ++- src/jit.zig | 44 +- src/platform.zig | 170 ++++++ src/trace.zig | 22 +- src/types.zig | 19 +- src/wasi.zig | 751 +++++++++++++++++++----- src/x86.zig | 409 ++++++++----- test/e2e/convert.py | 202 +++++++ test/e2e/convert.sh | 250 +------- test/e2e/run_e2e.py | 56 ++ test/e2e/run_e2e.sh | 61 +- test/realworld/build_all.py | 199 +++++++ test/realworld/build_all.sh | 163 +---- test/realworld/run_compat.py | 123 ++++ test/realworld/run_compat.sh | 127 +--- test/realworld/rust/file_io/src/main.rs | 10 +- test/spec/convert.py | 104 ++++ test/spec/convert.sh | 94 +-- test/spec/run_spec.py | 124 ++-- 26 files changed, 2138 insertions(+), 1129 deletions(-) create mode 100644 install.ps1 create mode 100644 src/platform.zig create mode 100644 test/e2e/convert.py create mode 100644 test/e2e/run_e2e.py create mode 100644 test/realworld/build_all.py create mode 100644 test/realworld/run_compat.py create mode 100644 test/spec/convert.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e41accc7..6574c8d2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,10 +8,13 @@ on: jobs: test: + defaults: + run: + shell: bash strategy: fail-fast: false matrix: - os: [ubuntu-latest, macos-latest] + os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -21,6 +24,11 @@ jobs: with: version: 0.15.2 + - name: Install Python + uses: actions/setup-python@v5 + with: + python-version: '3.x' + - name: Cache Zig build artifacts uses: actions/cache@v4 with: @@ -28,6 +36,7 @@ jobs: .zig-cache zig-cache ~/.cache/zig + ~/AppData/Local/zig key: zig-${{ runner.os }}-${{ hashFiles('build.zig', 'build.zig.zon', 'src/**/*.zig') }} restore-keys: | zig-${{ runner.os }}- @@ -38,43 +47,48 @@ jobs: - name: Run unit tests run: zig build test + - name: Run C API tests + run: zig build c-test + + - name: Setup Rust + run: | + source .github/tool-versions + rustup install "$RUST_VERSION" --no-self-update + rustup default "$RUST_VERSION" + rustup target add wasm32-wasip1 + rustc --version + - name: Install wasm-tools run: | source .github/tool-versions - ARCH=$(uname -m) - [ "$ARCH" = "arm64" ] && ARCH="aarch64" - OS=${{ runner.os == 'Linux' && '"linux"' || '"macos"' }} - ARCHIVE="wasm-tools-${WASM_TOOLS_VERSION}-${ARCH}-${OS}" - curl -L -o /tmp/wasm-tools.tar.gz \ - "https://github.com/bytecodealliance/wasm-tools/releases/download/v${WASM_TOOLS_VERSION}/${ARCHIVE}.tar.gz" - tar xzf /tmp/wasm-tools.tar.gz -C /tmp - echo "/tmp/${ARCHIVE}" >> "$GITHUB_PATH" + cargo install wasm-tools --locked --version "$WASM_TOOLS_VERSION" - name: Download WebAssembly spec testsuite - run: git clone --depth 1 https://github.com/WebAssembly/spec.git /tmp/wasm-spec + run: git clone --depth 1 https://github.com/WebAssembly/spec.git "${{ runner.temp }}/wasm-spec" - name: Convert spec tests - run: bash test/spec/convert.sh /tmp/wasm-spec/test/core + run: python test/spec/convert.py "${{ runner.temp }}/wasm-spec/test/core" - name: Run spec tests (strict) - run: python3 test/spec/run_spec.py --build --summary --strict + run: python test/spec/run_spec.py --build --summary --strict - name: Download wasmtime misc_testsuite run: | git clone --depth 1 --filter=blob:none --sparse \ - https://github.com/bytecodealliance/wasmtime.git /tmp/wasmtime - cd /tmp/wasmtime + https://github.com/bytecodealliance/wasmtime.git "${{ runner.temp }}/wasmtime" + cd "${{ runner.temp }}/wasmtime" git sparse-checkout set tests/misc_testsuite - name: Run E2E tests env: - WASMTIME_MISC_DIR: /tmp/wasmtime/tests/misc_testsuite - run: bash test/e2e/run_e2e.sh --convert --summary + WASMTIME_MISC_DIR: ${{ runner.temp }}/wasmtime/tests/misc_testsuite + run: python test/e2e/run_e2e.py --convert --summary - name: Build ReleaseSafe run: zig build -Doptimize=ReleaseSafe - name: Binary size check + if: runner.os != 'Windows' run: | BINARY=zig-out/bin/zwasm # Strip debug info for size measurement (Linux ELF includes DWARF @@ -82,10 +96,10 @@ jobs: cp "$BINARY" /tmp/zwasm_size_check strip /tmp/zwasm_size_check 2>/dev/null || true SIZE_BYTES=$(wc -c < /tmp/zwasm_size_check | tr -d ' ') - SIZE_MB=$(python3 -c "print(f'{$SIZE_BYTES / 1048576:.2f}')") + SIZE_MB=$(python -c "print(f'{$SIZE_BYTES / 1048576:.2f}')") LIMIT_BYTES=1572864 RAW_BYTES=$(wc -c < "$BINARY" | tr -d ' ') - RAW_MB=$(python3 -c "print(f'{$RAW_BYTES / 1048576:.2f}')") + RAW_MB=$(python -c "print(f'{$RAW_BYTES / 1048576:.2f}')") echo "Binary size (raw): ${RAW_MB} MB ($RAW_BYTES bytes)" echo "Binary size (stripped): ${SIZE_MB} MB ($SIZE_BYTES bytes)" if [ "$SIZE_BYTES" -gt "$LIMIT_BYTES" ]; then @@ -95,6 +109,7 @@ jobs: echo "PASS: Within 1.5 MB limit" - name: Memory usage check + if: runner.os != 'Windows' run: | BINARY=zig-out/bin/zwasm LIMIT_KB=4608 # 4.5 MB @@ -106,7 +121,7 @@ jobs: MEM_OUTPUT=$(/usr/bin/time -v "$BINARY" --invoke sieve bench/wasm/sieve.wasm 1000000 2>&1 >/dev/null) MEM_KB=$(echo "$MEM_OUTPUT" | grep "Maximum resident set size" | awk '{print $NF}') fi - MEM_MB=$(python3 -c "print(f'{int(${MEM_KB}) / 1024:.2f}')") + MEM_MB=$(python -c "print(f'{int(${MEM_KB}) / 1024:.2f}')") echo "Peak memory: ${MEM_MB} MB (${MEM_KB} KB)" if [ "$MEM_KB" -gt "$LIMIT_KB" ]; then echo "FAIL: Peak memory exceeds 4.5 MB limit" @@ -117,49 +132,81 @@ jobs: - name: Install wasmtime run: | source .github/tool-versions - if [ "$(uname)" = "Darwin" ]; then - ARCH="aarch64"; OS="macos" + if [ "${{ runner.os }}" = "macOS" ]; then + ARCH="aarch64"; OS="macos"; EXT="tar.xz"; BIN_NAME="wasmtime" + elif [ "${{ runner.os }}" = "Windows" ]; then + ARCH="x86_64"; OS="windows"; EXT="zip"; BIN_NAME="wasmtime.exe" else - ARCH="x86_64"; OS="linux" + ARCH="x86_64"; OS="linux"; EXT="tar.xz"; BIN_NAME="wasmtime" fi - ARCHIVE="wasmtime-v${WASMTIME_VERSION}-${ARCH}-${OS}.tar.xz" - curl -L -o /tmp/wasmtime.tar.xz \ + ARCHIVE="wasmtime-v${WASMTIME_VERSION}-${ARCH}-${OS}.${EXT}" + curl -L -o "${{ runner.temp }}/wasmtime.${EXT}" \ "https://github.com/bytecodealliance/wasmtime/releases/download/v${WASMTIME_VERSION}/${ARCHIVE}" mkdir -p "$HOME/.wasmtime/bin" - tar xJf /tmp/wasmtime.tar.xz -C /tmp - cp "/tmp/wasmtime-v${WASMTIME_VERSION}-${ARCH}-${OS}/wasmtime" "$HOME/.wasmtime/bin/" + if [ "$EXT" = "zip" ]; then + unzip -q "${{ runner.temp }}/wasmtime.${EXT}" -d "${{ runner.temp }}/wasmtime-extract" + cp "${{ runner.temp }}/wasmtime-extract/wasmtime-v${WASMTIME_VERSION}-${ARCH}-${OS}/${BIN_NAME}" "$HOME/.wasmtime/bin/" + else + tar xJf "${{ runner.temp }}/wasmtime.${EXT}" -C "${{ runner.temp }}" + cp "${{ runner.temp }}/wasmtime-v${WASMTIME_VERSION}-${ARCH}-${OS}/${BIN_NAME}" "$HOME/.wasmtime/bin/" + fi - name: Install WASI SDK + if: runner.os != 'Windows' run: | source .github/tool-versions - if [ "$(uname)" = "Darwin" ]; then + if [ "${{ runner.os }}" = "macOS" ]; then ARCH="arm64"; OS="macos" else ARCH="x86_64"; OS="linux" fi ARCHIVE="wasi-sdk-${WASI_SDK_VERSION}.0-${ARCH}-${OS}.tar.gz" - curl -L -o /tmp/wasi-sdk.tar.gz \ + curl -L -o "${{ runner.temp }}/wasi-sdk.tar.gz" \ "https://github.com/WebAssembly/wasi-sdk/releases/download/wasi-sdk-${WASI_SDK_VERSION}/${ARCHIVE}" - sudo mkdir -p /opt/wasi-sdk - sudo tar xzf /tmp/wasi-sdk.tar.gz -C /opt/wasi-sdk --strip-components=1 + mkdir -p "${{ runner.temp }}/wasi-sdk" + tar xzf "${{ runner.temp }}/wasi-sdk.tar.gz" -C "${{ runner.temp }}/wasi-sdk" --strip-components=1 - - name: Setup Rust wasm32-wasip1 target + - name: Install WASI SDK (Windows) + if: runner.os == 'Windows' + shell: python run: | - source .github/tool-versions - rustup install "$RUST_VERSION" --no-self-update - rustup default "$RUST_VERSION" - rustup target add wasm32-wasip1 - rustc --version + import pathlib + import tarfile + import urllib.request + + tool_versions = pathlib.Path(".github/tool-versions").read_text(encoding="utf-8").splitlines() + wasi_sdk_version = next( + line.split("=", 1)[1].strip().strip('"') + for line in tool_versions + if line.startswith("WASI_SDK_VERSION=") + ) + archive = pathlib.Path(r"${{ runner.temp }}\wasi-sdk.tar.gz") + dest = pathlib.Path(r"${{ runner.temp }}\wasi-sdk") + dest.mkdir(parents=True, exist_ok=True) + + url = ( + f"https://github.com/WebAssembly/wasi-sdk/releases/download/" + f"wasi-sdk-{wasi_sdk_version}/wasi-sdk-{wasi_sdk_version}.0-x86_64-windows.tar.gz" + ) + urllib.request.urlretrieve(url, archive) + + with tarfile.open(archive, "r:gz") as tf: + for member in tf.getmembers(): + parts = pathlib.PurePosixPath(member.name).parts + if len(parts) <= 1: + continue + member.name = str(pathlib.PurePosixPath(*parts[1:])) + tf.extract(member, dest) - name: Build real-world wasm programs env: - WASI_SDK_PATH: /opt/wasi-sdk - run: bash test/realworld/build_all.sh + WASI_SDK_PATH: ${{ runner.temp }}/wasi-sdk + run: python test/realworld/build_all.py - name: Run real-world compat tests run: | export PATH="$HOME/.wasmtime/bin:$PATH" - bash test/realworld/run_compat.sh + python test/realworld/run_compat.py size-matrix: runs-on: ubuntu-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4829a21e..bdb69270 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,18 +9,28 @@ permissions: jobs: build: + defaults: + run: + shell: bash strategy: matrix: include: - os: macos-latest target: aarch64-macos artifact: zwasm-macos-aarch64 + package: tar.gz - os: ubuntu-latest target: x86_64-linux artifact: zwasm-linux-x86_64 + package: tar.gz - os: ubuntu-latest target: aarch64-linux artifact: zwasm-linux-aarch64 + package: tar.gz + - os: windows-latest + target: x86_64-windows + artifact: zwasm-windows-x86_64 + package: zip runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v4 @@ -38,18 +48,33 @@ jobs: - name: Package binary env: ARTIFACT_NAME: ${{ matrix.artifact }} + PACKAGE_EXT: ${{ matrix.package }} run: | mkdir -p dist - cp zig-out/bin/zwasm dist/zwasm - cd dist - tar czf "${ARTIFACT_NAME}.tar.gz" zwasm - shasum -a 256 "${ARTIFACT_NAME}.tar.gz" > "${ARTIFACT_NAME}.tar.gz.sha256" + checksum() { + if command -v sha256sum >/dev/null 2>&1; then + sha256sum "$1" > "$1.sha256" + else + shasum -a 256 "$1" > "$1.sha256" + fi + } + if [ "${{ runner.os }}" = "Windows" ]; then + cp zig-out/bin/zwasm.exe dist/zwasm.exe + cd dist + powershell -Command "Compress-Archive -Path zwasm.exe -DestinationPath '${ARTIFACT_NAME}.zip'" + checksum "${ARTIFACT_NAME}.zip" + else + cp zig-out/bin/zwasm dist/zwasm + cd dist + tar czf "${ARTIFACT_NAME}.tar.gz" zwasm + checksum "${ARTIFACT_NAME}.tar.gz" + fi - name: Upload artifact uses: actions/upload-artifact@v4 with: name: ${{ matrix.artifact }} - path: dist/${{ matrix.artifact }}.tar.gz* + path: dist/${{ matrix.artifact }}.* release: needs: build @@ -67,7 +92,8 @@ jobs: cd artifacts find . -name '*.sha256' -exec cat {} \; > ../SHA256SUMS cd .. - mv artifacts/*/*.tar.gz . + find artifacts -name '*.tar.gz' -exec mv {} . \; + find artifacts -name '*.zip' -exec mv {} . \; - name: Create GitHub Release env: @@ -77,4 +103,4 @@ jobs: gh release create "$TAG_NAME" \ --title "$TAG_NAME" \ --generate-notes \ - *.tar.gz SHA256SUMS + *.tar.gz *.zip SHA256SUMS diff --git a/README.md b/README.md index 16716409..59c0de93 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,12 @@ A small, fast WebAssembly runtime written in Zig. Library and CLI. +Supported host targets: +- `aarch64-macos` +- `x86_64-linux` +- `aarch64-linux` +- `x86_64-windows` + ## Why zwasm > **Note**: zwasm is under active development. Real-world Wasm compatibility @@ -27,7 +33,7 @@ zwasm was extracted from [ClojureWasm](https://github.com/clojurewasm/ClojureWas - **581+ opcodes**: Full MVP + SIMD (236 + 20 relaxed) + Exception handling + Function references + GC + Threads (79 atomics) - **4-tier execution**: bytecode > predecoded IR > register IR > ARM64/x86_64 JIT -- **100% spec conformance**: 62,263/62,263 spec tests, 792/792 E2E tests, 50 real-world programs (Mac + Ubuntu) +- **100% spec conformance**: 62,263/62,263 spec tests, 792/792 E2E tests, 50 real-world programs (macOS + Linux + Windows x86_64 CI) - **All Wasm 3.0 proposals**: See [Spec Coverage](#wasm-spec-coverage) below - **Component Model**: WIT parser, Canonical ABI, component linking, WASI P2 adapter - **WAT support**: `zwasm file.wat`, build-time optional (`-Dwat=false`) @@ -76,16 +82,22 @@ Full results (29 benchmarks): `bench/runtime_comparison.yaml` ## Install +macOS / Linux: + ```bash -# From source (requires Zig 0.15.2) zig build -Doptimize=ReleaseSafe cp zig-out/bin/zwasm ~/.local/bin/ -# Or use the install script curl -fsSL https://raw.githubusercontent.com/clojurewasm/zwasm/main/install.sh | bash +``` + +Windows (PowerShell): -# Or via Homebrew (macOS/Linux) — coming soon -# brew install clojurewasm/tap/zwasm +```powershell +zig build -Doptimize=ReleaseSafe +Copy-Item zig-out\bin\zwasm.exe "$env:LOCALAPPDATA\Microsoft\WindowsApps\zwasm.exe" + +irm https://raw.githubusercontent.com/clojurewasm/zwasm/main/install.ps1 | iex ``` ## Usage @@ -125,7 +137,7 @@ See [docs/usage.md](docs/usage.md) for detailed library and CLI documentation. zwasm also exposes a C API for use from any FFI-capable language (C, Python, Rust, Go, etc.): ```bash -zig build lib # Build libzwasm (.dylib / .so / .a) +zig build lib # Build libzwasm (.dll/.lib, .dylib/.a, or .so/.a) ``` ```c @@ -182,9 +194,12 @@ Requires Zig 0.15.2. ```bash zig build # Build (Debug) zig build test # Run all tests (521 tests) +zig build c-test # Run C API tests ./zig-out/bin/zwasm run file.wasm ``` +On Windows use `zig-out\bin\zwasm.exe`. + ### Feature flags Strip features at compile time to reduce binary size: @@ -237,8 +252,8 @@ aim to replace wasmtime for general use. Instead, it targets environments where size and startup time matter: embedded systems, edge computing, CLI tools, and as an embeddable library in Zig projects. -**ARM64-first, x86_64 supported.** Primary optimization on Apple Silicon and ARM64 Linux. -x86_64 JIT also available for Linux server deployment. +**ARM64-first, x86_64 supported.** Primary optimization is still Apple Silicon and ARM64 Linux. +x86_64 JIT is supported on Linux and Windows. **Spec fidelity over expedience.** Correctness comes before performance. The spec test suite runs on every change. @@ -257,7 +272,8 @@ The spec test suite runs on every change. - [x] Stage 23: JIT optimization (smart spill, direct call, FP cache, self-call inline) - [x] Stage 25: Lightweight self-call (fib now matches wasmtime) - [x] Stages 26-31: JIT peephole, platform verification, spec cleanup, GC benchmarks -- [x] Stage 32: 100% spec conformance (62,263/62,263 on Mac + Ubuntu) +- [x] Stage 32: 100% spec conformance (62,263/62,263 on macOS + Linux) +- [x] Stage 33: Windows x86_64 native support (build, test, JIT, C API, release artifacts) - [x] Stage 33: Fuzz testing (differential testing, extended fuzz campaign, 0 crashes) - [x] Stages 35-41: Production hardening (crash safety, CI/CD, docs, API stability, distribution) - [x] Stages 42-43: Community preparation, v1.0.0 release diff --git a/build.zig b/build.zig index af2fc7da..b1b5e478 100644 --- a/build.zig +++ b/build.zig @@ -194,6 +194,7 @@ pub fn build(b: *std.Build) void { .name = ct.name, .root_module = ct_mod, }); + ct_exe.linkLibC(); // Install only via c-test step (not default install) to keep artifact count // below Zig 0.15.2 build runner shuffle bug threshold on some platforms. c_test_step.dependOn(&b.addInstallArtifact(ct_exe, .{}).step); diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 00000000..1062b394 --- /dev/null +++ b/install.ps1 @@ -0,0 +1,43 @@ +$ErrorActionPreference = "Stop" + +param( + [string]$Prefix = "$env:LOCALAPPDATA\zwasm", + [string]$Version = "" +) + +$Repo = "clojurewasm/zwasm" +$BinDir = Join-Path $Prefix "bin" +$Artifact = "zwasm-windows-x86_64" + +if (-not $Version) { + $Release = Invoke-RestMethod "https://api.github.com/repos/$Repo/releases/latest" + $Version = $Release.tag_name + if (-not $Version) { + throw "Could not determine latest version" + } +} + +$Url = "https://github.com/$Repo/releases/download/$Version/$Artifact.zip" +$TempDir = Join-Path ([System.IO.Path]::GetTempPath()) ("zwasm-install-" + [System.Guid]::NewGuid().ToString("N")) +New-Item -ItemType Directory -Path $TempDir | Out-Null + +try { + $ZipPath = Join-Path $TempDir "$Artifact.zip" + Write-Host "Installing zwasm $Version (windows/x86_64)..." + Invoke-WebRequest -Uri $Url -OutFile $ZipPath + Expand-Archive -Path $ZipPath -DestinationPath $TempDir -Force + + New-Item -ItemType Directory -Path $BinDir -Force | Out-Null + Move-Item -Force (Join-Path $TempDir "zwasm.exe") (Join-Path $BinDir "zwasm.exe") + + Write-Host "Installed: $(Join-Path $BinDir 'zwasm.exe')" + $UserPath = [Environment]::GetEnvironmentVariable("Path", "User") + if (-not $UserPath.Split(';').Contains($BinDir)) { + Write-Host "" + Write-Host "Add to your PATH:" + Write-Host " [Environment]::SetEnvironmentVariable('Path', `"$UserPath;$BinDir`", 'User')" + } +} +finally { + Remove-Item -Recurse -Force $TempDir -ErrorAction SilentlyContinue +} diff --git a/src/cache.zig b/src/cache.zig index 5ec198eb..26174b35 100644 --- a/src/cache.zig +++ b/src/cache.zig @@ -21,6 +21,7 @@ const Allocator = std.mem.Allocator; const predecode_mod = @import("predecode.zig"); const PreInstr = predecode_mod.PreInstr; const IrFunc = predecode_mod.IrFunc; +const platform = @import("platform.zig"); pub const MAGIC: [8]u8 = "ZWCACHE\x00".*; pub const VERSION: u32 = 1; @@ -165,8 +166,7 @@ pub fn wasmHash(wasm_bin: []const u8) [32]u8 { /// Get cache directory path (~/.cache/zwasm/). Creates it if needed. /// Returns owned slice. Caller must free. pub fn getCacheDir(alloc: Allocator) ![]u8 { - const home = std.posix.getenv("HOME") orelse return error.NoCacheDir; - const path = try std.fmt.allocPrint(alloc, "{s}/.cache/zwasm", .{home}); + const path = try platform.appCacheDir(alloc, "zwasm"); // Ensure directory exists std.fs.makeDirAbsolute(path) catch |err| switch (err) { error.PathAlreadyExists => {}, diff --git a/src/cli.zig b/src/cli.zig index 4c81005b..c44bc46d 100644 --- a/src/cli.zig +++ b/src/cli.zig @@ -104,7 +104,8 @@ fn printUsage(w: *std.Io.Writer) void { \\ --invoke Call instead of _start \\ --batch Batch mode: read invocations from stdin \\ --link name=file Link a module as import source (repeatable) - \\ --dir Preopen a host directory (repeatable) + \\ --dir + \\ Preopen a host directory, optionally at a guest path \\ --env KEY=VALUE Set a WASI environment variable (repeatable) \\ --profile Print execution profile (opcode frequency, call counts) \\ --sandbox Deny all capabilities + fuel 1B + memory 256MB @@ -1302,10 +1303,11 @@ fn cmdBatch(allocator: Allocator, wasm_bytes: []const u8, imports: []const types } while (true) { - const line = r.takeDelimiter('\n') catch |err| switch (err) { + const raw_line = r.takeDelimiter('\n') catch |err| switch (err) { error.StreamTooLong => continue, else => break, } orelse break; + const line = std.mem.trimRight(u8, raw_line, "\r"); // Skip empty lines if (line.len == 0) continue; @@ -1480,7 +1482,8 @@ fn cmdBatch(allocator: Allocator, wasm_bytes: []const u8, imports: []const types }; // Buffer invocations until thread_end while (true) { - const tline = r.takeDelimiter('\n') catch break orelse break; + const raw_tline = r.takeDelimiter('\n') catch break orelse break; + const tline = std.mem.trimRight(u8, raw_tline, "\r"); if (std.mem.eql(u8, tline, "thread_end")) break; if (!std.mem.startsWith(u8, tline, "invoke ")) continue; // Parse: invoke : [args...] diff --git a/src/guard.zig b/src/guard.zig index 858bb9ef..26246739 100644 --- a/src/guard.zig +++ b/src/guard.zig @@ -13,8 +13,12 @@ const std = @import("std"); const builtin = @import("builtin"); +const platform = @import("platform.zig"); +const page_size = std.heap.page_size_min; + const posix = std.posix; -const PROT = std.c.PROT; +const windows = std.os.windows; +const kernel32 = std.os.windows.kernel32; /// Guard region size: 4 GiB + 64 KiB. /// This ensures any 32-bit index (0..0xFFFFFFFF) + small offset (up to 64 KiB) @@ -52,14 +56,7 @@ pub const GuardedMem = struct { /// Allocate a guarded memory region. Initially all pages are PROT_NONE. /// Call `makeAccessible` to enable read/write for the data portion. pub fn init() !GuardedMem { - const buf = posix.mmap( - null, - TOTAL_RESERVATION, - PROT.NONE, - .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, - -1, - 0, - ) catch return error.MmapFailed; + const buf = platform.reservePages(TOTAL_RESERVATION, .none) catch return error.MmapFailed; return .{ .base = @alignCast(buf.ptr), .accessible = 0, @@ -75,10 +72,9 @@ pub const GuardedMem = struct { } if (size > TOTAL_RESERVATION - GUARD_SIZE) return error.ExceedsCapacity; // Round up to page boundary - const page_size = std.heap.page_size_min; const aligned = (size + page_size - 1) & ~(page_size - 1); const region: []align(std.heap.page_size_min) u8 = @alignCast(self.base[0..aligned]); - posix.mprotect(region, PROT.READ | PROT.WRITE) catch return error.MprotectFailed; + platform.commitPages(region, .read_write) catch return error.MprotectFailed; self.accessible = size; } @@ -89,12 +85,11 @@ pub const GuardedMem = struct { const new_size = old + delta; if (new_size > TOTAL_RESERVATION - GUARD_SIZE) return error.ExceedsCapacity; // Only need to mprotect the newly added pages - const page_size = std.heap.page_size_min; const old_aligned = (old + page_size - 1) & ~(page_size - 1); const new_aligned = (new_size + page_size - 1) & ~(page_size - 1); if (new_aligned > old_aligned) { const region: []align(std.heap.page_size_min) u8 = @alignCast(self.base[old_aligned..new_aligned]); - posix.mprotect(region, PROT.READ | PROT.WRITE) catch return error.MprotectFailed; + platform.commitPages(region, .read_write) catch return error.MprotectFailed; } self.accessible = new_size; return old; @@ -108,7 +103,7 @@ pub const GuardedMem = struct { /// Release the entire mmap'd region. pub fn deinit(self: *GuardedMem) void { const region: []align(std.heap.page_size_min) u8 = @alignCast(self.base[0..TOTAL_RESERVATION]); - posix.munmap(region); + platform.freePages(region); self.base = undefined; self.accessible = 0; } @@ -132,6 +127,11 @@ pub fn getRecovery() *RecoveryInfo { /// Install the signal handler for guard page faults. /// Must be called once at startup. pub fn installSignalHandler() void { + if (comptime builtin.os.tag == .windows) { + installWindowsHandler(); + return; + } + const handler_fn = struct { fn handler(_: i32, _: *const posix.siginfo_t, ctx_ptr: ?*anyopaque) callconv(.c) void { const rec = getRecovery(); @@ -176,6 +176,37 @@ pub fn installSignalHandler() void { } } +var windows_handler_installed = false; + +fn installWindowsHandler() void { + if (windows_handler_installed) return; + const handle = kernel32.AddVectoredExceptionHandler(1, windowsHandler); + if (handle != null) { + windows_handler_installed = true; + } +} + +fn windowsHandler(info: *windows.EXCEPTION_POINTERS) callconv(.winapi) c_long { + const rec = getRecovery(); + if (!rec.active) return windows.EXCEPTION_CONTINUE_SEARCH; + + const record = info.ExceptionRecord; + if (record.ExceptionCode != windows.EXCEPTION_ACCESS_VIOLATION) { + return windows.EXCEPTION_CONTINUE_SEARCH; + } + + const ctx = info.ContextRecord; + const faulting_pc = getWindowsPc(ctx); + if (faulting_pc < rec.jit_code_start or faulting_pc >= rec.jit_code_end) { + return windows.EXCEPTION_CONTINUE_SEARCH; + } + + setWindowsPc(ctx, rec.oob_exit_pc); + setWindowsReturnReg(ctx, 6); + rec.active = false; + return @as(c_long, -1); // EXCEPTION_CONTINUE_EXECUTION +} + fn resetAndReraise() void { // Reset to default handler and re-raise — this will crash as expected const default_act = posix.Sigaction{ @@ -249,6 +280,30 @@ fn setReturnReg(ctx: *align(1) posix.ucontext_t, value: u64) void { } } +fn getWindowsPc(ctx: *windows.CONTEXT) usize { + return switch (builtin.cpu.arch) { + .aarch64 => @intCast(ctx.Pc), + .x86_64 => @intCast(ctx.Rip), + else => @compileError("unsupported Windows arch for guard pages"), + }; +} + +fn setWindowsPc(ctx: *windows.CONTEXT, pc: usize) void { + switch (builtin.cpu.arch) { + .aarch64 => ctx.Pc = pc, + .x86_64 => ctx.Rip = pc, + else => @compileError("unsupported Windows arch for guard pages"), + } +} + +fn setWindowsReturnReg(ctx: *windows.CONTEXT, value: u64) void { + switch (builtin.cpu.arch) { + .aarch64 => ctx.DUMMYUNIONNAME.DUMMYSTRUCTNAME.X0 = value, + .x86_64 => ctx.Rax = value, + else => @compileError("unsupported Windows arch for guard pages"), + } +} + // ============================================================ // Tests // ============================================================ diff --git a/src/jit.zig b/src/jit.zig index f4c94b8e..c32d94b2 100644 --- a/src/jit.zig +++ b/src/jit.zig @@ -33,6 +33,7 @@ const ValType = @import("opcode.zig").ValType; const WasmMemory = @import("memory.zig").Memory; const trace_mod = @import("trace.zig"); const predecode_mod = @import("predecode.zig"); +const platform = @import("platform.zig"); /// JIT-compiled function pointer type. /// Args: regs_ptr, vm_ptr, instance_ptr. @@ -52,14 +53,18 @@ pub const JitCode = struct { osr_entry: ?JitFn = null, pub fn deinit(self: *JitCode, alloc: Allocator) void { - std.posix.munmap(self.buf); + platform.freePages(self.buf); alloc.destroy(self); } }; -/// Returns true if JIT compilation is supported on the current CPU architecture. +/// Returns true if JIT compilation is supported on the current CPU architecture and host OS. pub fn jitSupported() bool { - return builtin.cpu.arch == .aarch64 or builtin.cpu.arch == .x86_64; + return switch (builtin.os.tag) { + .linux, .macos => builtin.cpu.arch == .aarch64 or builtin.cpu.arch == .x86_64, + .windows => builtin.cpu.arch == .x86_64, + else => false, + }; } /// Hot function call threshold — JIT after this many calls. @@ -4729,33 +4734,24 @@ pub const Compiler = struct { const page_size = std.heap.page_size_min; const buf_size = std.mem.alignForward(usize, code_size, page_size); - const PROT = std.posix.PROT; - const buf = std.posix.mmap( - null, - buf_size, - PROT.READ | PROT.WRITE, - .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, - -1, - 0, - ) catch return null; - const aligned_buf: []align(std.heap.page_size_min) u8 = @alignCast(buf); + const aligned_buf = platform.allocatePages(buf_size, .read_write) catch return null; // Copy instructions to executable buffer const src_bytes = std.mem.sliceAsBytes(self.code.items); @memcpy(aligned_buf[0..src_bytes.len], src_bytes); // Make executable (W^X transition) - std.posix.mprotect(aligned_buf, PROT.READ | PROT.EXEC) catch { - std.posix.munmap(aligned_buf); + platform.protectPages(aligned_buf, .read_exec) catch { + platform.freePages(aligned_buf); return null; }; // Flush instruction cache - icacheInvalidate(aligned_buf.ptr, code_size); + platform.flushInstructionCache(aligned_buf.ptr, code_size); // Allocate JitCode struct const jit_code = self.alloc.create(JitCode) catch { - std.posix.munmap(aligned_buf); + platform.freePages(aligned_buf); return null; }; jit_code.* = .{ @@ -5225,20 +5221,6 @@ pub fn compileFunction( // Instruction cache flush // ================================================================ -fn icacheInvalidate(ptr: [*]const u8, len: usize) void { - if (builtin.os.tag == .macos) { - const func = @extern(*const fn ([*]const u8, usize) callconv(.c) void, .{ - .name = "sys_icache_invalidate", - }); - func(ptr, len); - } else if (builtin.os.tag == .linux) { - const func = @extern(*const fn ([*]const u8, [*]const u8) callconv(.c) void, .{ - .name = "__clear_cache", - }); - func(ptr, ptr + len); - } -} - // ================================================================ // Tests // ================================================================ diff --git a/src/platform.zig b/src/platform.zig new file mode 100644 index 00000000..4b863081 --- /dev/null +++ b/src/platform.zig @@ -0,0 +1,170 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +pub const windows = std.os.windows; +pub const kernel32 = std.os.windows.kernel32; + +const page_size = std.heap.page_size_min; + +pub const Protection = enum { + none, + read_write, + read_exec, +}; + +pub const PageError = error{ + OutOfMemory, + PermissionDenied, + InvalidAddress, + Unexpected, +}; + +pub fn reservePages(size: usize, prot: Protection) PageError![]align(page_size) u8 { + if (builtin.os.tag == .windows) { + const addr = kernel32.VirtualAlloc(null, size, windows.MEM_RESERVE, protectionToWin(prot)) orelse + return error.OutOfMemory; + const ptr: [*]align(page_size) u8 = @ptrCast(@alignCast(addr)); + return ptr[0..size]; + } + + const posix = std.posix; + const buf = posix.mmap( + null, + size, + protectionToPosix(prot), + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ) catch return error.OutOfMemory; + return @alignCast(buf); +} + +pub fn allocatePages(size: usize, prot: Protection) PageError![]align(page_size) u8 { + if (builtin.os.tag == .windows) { + const addr = kernel32.VirtualAlloc( + null, + size, + windows.MEM_RESERVE | windows.MEM_COMMIT, + protectionToWin(prot), + ) orelse return error.OutOfMemory; + const ptr: [*]align(page_size) u8 = @ptrCast(@alignCast(addr)); + return ptr[0..size]; + } + + return reservePages(size, prot); +} + +pub fn commitPages(region: []align(page_size) u8, prot: Protection) PageError!void { + if (region.len == 0) return; + + if (builtin.os.tag == .windows) { + const addr = kernel32.VirtualAlloc( + region.ptr, + region.len, + windows.MEM_COMMIT, + protectionToWin(prot), + ) orelse return error.OutOfMemory; + if (@intFromPtr(addr) != @intFromPtr(region.ptr)) return error.Unexpected; + return; + } + + const posix = std.posix; + posix.mprotect(region, protectionToPosix(prot)) catch return error.PermissionDenied; +} + +pub fn protectPages(region: []align(page_size) u8, prot: Protection) PageError!void { + if (region.len == 0) return; + + if (builtin.os.tag == .windows) { + var old_protect: windows.DWORD = 0; + windows.VirtualProtect(region.ptr, region.len, protectionToWin(prot), &old_protect) catch |err| switch (err) { + error.InvalidAddress => return error.InvalidAddress, + else => return error.Unexpected, + }; + return; + } + + const posix = std.posix; + posix.mprotect(region, protectionToPosix(prot)) catch return error.PermissionDenied; +} + +pub fn freePages(region: []align(page_size) u8) void { + if (region.len == 0) return; + + if (builtin.os.tag == .windows) { + windows.VirtualFree(region.ptr, 0, windows.MEM_RELEASE); + return; + } + + std.posix.munmap(region); +} + +pub fn flushInstructionCache(ptr: [*]const u8, len: usize) void { + if (len == 0) return; + + if (builtin.os.tag == .windows) { + _ = FlushInstructionCache(windows.GetCurrentProcess(), ptr, len); + } else if (builtin.os.tag == .macos) { + const func = @extern(*const fn ([*]const u8, usize) callconv(.c) void, .{ + .name = "sys_icache_invalidate", + }); + func(ptr, len); + } else if (builtin.os.tag == .linux) { + const func = @extern(*const fn ([*]const u8, [*]const u8) callconv(.c) void, .{ + .name = "__clear_cache", + }); + func(ptr, ptr + len); + } +} + +pub fn appCacheDir(alloc: std.mem.Allocator, app_name: []const u8) ![]u8 { + if (builtin.os.tag == .windows) { + return std.fs.getAppDataDir(alloc, app_name); + } + + const home = std.posix.getenv("HOME") orelse return error.NoCacheDir; + return std.fmt.allocPrint(alloc, "{s}/.cache/{s}", .{ home, app_name }); +} + +pub fn tempDirPath(alloc: std.mem.Allocator) ![]u8 { + if (builtin.os.tag == .windows) { + if (try envPath(alloc, "TEMP")) |path| return path; + if (try envPath(alloc, "TMP")) |path| return path; + } else { + if (try envPath(alloc, "TMPDIR")) |path| return path; + } + + if (builtin.os.tag == .windows) { + return std.fs.getAppDataDir(alloc, "Temp"); + } + return alloc.dupe(u8, "/tmp"); +} + +fn envPath(alloc: std.mem.Allocator, name: []const u8) !?[]u8 { + return std.process.getEnvVarOwned(alloc, name) catch |err| switch (err) { + error.EnvironmentVariableNotFound => null, + else => err, + }; +} + +fn protectionToWin(prot: Protection) windows.DWORD { + return switch (prot) { + .none => windows.PAGE_NOACCESS, + .read_write => windows.PAGE_READWRITE, + .read_exec => windows.PAGE_EXECUTE_READ, + }; +} + +fn protectionToPosix(prot: Protection) u32 { + return switch (prot) { + .none => @intCast(std.posix.PROT.NONE), + .read_write => @intCast(std.posix.PROT.READ | std.posix.PROT.WRITE), + .read_exec => @intCast(std.posix.PROT.READ | std.posix.PROT.EXEC), + }; +} + +extern "kernel32" fn FlushInstructionCache( + hProcess: windows.HANDLE, + lpBaseAddress: ?*const anyopaque, + dwSize: windows.SIZE_T, +) callconv(.winapi) windows.BOOL; diff --git a/src/trace.zig b/src/trace.zig index 982c50e4..67a1d23c 100644 --- a/src/trace.zig +++ b/src/trace.zig @@ -12,6 +12,7 @@ const Allocator = std.mem.Allocator; const regalloc_mod = @import("regalloc.zig"); const RegFunc = regalloc_mod.RegFunc; const RegInstr = regalloc_mod.RegInstr; +const platform = @import("platform.zig"); /// Trace category bitmask for O(1) enable check. pub const TraceCategory = enum(u3) { @@ -416,7 +417,7 @@ pub fn dumpRegIR(w: *std.Io.Writer, reg_func: *const RegFunc, pool64: []const u6 // ================================================================ /// Dump JIT-compiled ARM64 code for a function. -/// Writes raw binary to /tmp, attempts llvm-objdump, falls back to hex. +/// Writes raw binary to the host temp directory, attempts llvm-objdump, falls back to hex. pub fn dumpJitCode( alloc: Allocator, code_items: []const u32, @@ -432,15 +433,26 @@ pub fn dumpJitCode( func_idx, code_items.len, code_bytes, }) catch {}; - // Write raw binary to /tmp - var path_buf: [64]u8 = undefined; - const bin_path = std.fmt.bufPrint(&path_buf, "/tmp/zwasm_jit_{d}.bin", .{func_idx}) catch { + const tmp_dir = platform.tempDirPath(alloc) catch { + w.print(" (failed to resolve temp dir)\n", .{}) catch {}; + w.flush() catch {}; + return; + }; + defer alloc.free(tmp_dir); + const file_name = std.fmt.allocPrint(alloc, "zwasm_jit_{d}.bin", .{func_idx}) catch { + w.print(" (failed to format path)\n", .{}) catch {}; + w.flush() catch {}; + return; + }; + defer alloc.free(file_name); + const bin_path = std.fs.path.join(alloc, &.{ tmp_dir, file_name }) catch { w.print(" (failed to format path)\n", .{}) catch {}; w.flush() catch {}; return; }; + defer alloc.free(bin_path); - const file = std.fs.cwd().createFile(bin_path, .{}) catch { + const file = std.fs.createFileAbsolute(bin_path, .{}) catch { w.print(" (failed to create {s})\n", .{bin_path}) catch {}; w.flush() catch {}; return; diff --git a/src/types.zig b/src/types.zig index c0aac03d..af1e865f 100644 --- a/src/types.zig +++ b/src/types.zig @@ -185,6 +185,17 @@ pub const WasiOptions = struct { caps: rt.wasi.Capabilities = rt.wasi.Capabilities.cli_default, }; +fn splitPreopenSpec(spec: []const u8) struct { host: []const u8, guest: []const u8 } { + if (std.mem.indexOf(u8, spec, "::")) |sep| { + const host = spec[0..sep]; + const guest = spec[sep + 2 ..]; + if (host.len != 0 and guest.len != 0) { + return .{ .host = host, .guest = guest }; + } + } + return .{ .host = spec, .guest = spec }; +} + // ============================================================ // WasmModule — loaded and instantiated Wasm module // ============================================================ @@ -258,8 +269,8 @@ pub const WasmModule = struct { for (opts.preopen_paths, 0..) |path, i| { const fd: i32 = @intCast(3 + i); - const host_fd = std.posix.open(path, .{ .ACCMODE = .RDONLY }, 0) catch continue; - try wc.addPreopen(fd, path, host_fd); + const spec = splitPreopenSpec(path); + wc.addPreopenPath(fd, spec.guest, spec.host) catch continue; } } @@ -287,8 +298,8 @@ pub const WasmModule = struct { for (opts.preopen_paths, 0..) |path, ii| { const fd: i32 = @intCast(3 + ii); - const host_fd = std.posix.open(path, .{ .ACCMODE = .RDONLY }, 0) catch continue; - try wc.addPreopen(fd, path, host_fd); + const spec = splitPreopenSpec(path); + wc.addPreopenPath(fd, spec.guest, spec.host) catch continue; } } diff --git a/src/wasi.zig b/src/wasi.zig index 2b69b7c4..414a1913 100644 --- a/src/wasi.zig +++ b/src/wasi.zig @@ -12,6 +12,8 @@ const builtin = @import("builtin"); const posix = std.posix; const mem = std.mem; const Allocator = mem.Allocator; +const windows = std.os.windows; +const kernel32 = std.os.windows.kernel32; const vm_mod = @import("vm.zig"); const Vm = vm_mod.Vm; const WasmError = vm_mod.WasmError; @@ -137,16 +139,76 @@ pub const ClockId = enum(u32) { // Preopened directory // ============================================================ +const HandleKind = enum { + file, + dir, +}; + +const HostHandle = struct { + raw: std.fs.File.Handle, + kind: HandleKind, + + fn file(self: HostHandle) std.fs.File { + return .{ .handle = self.raw }; + } + + fn dir(self: HostHandle) std.fs.Dir { + return .{ .fd = self.raw }; + } + + fn close(self: HostHandle) void { + switch (self.kind) { + .file => { + var host_file = self.file(); + host_file.close(); + }, + .dir => { + var host_dir = self.dir(); + host_dir.close(); + }, + } + } + + fn stat(self: HostHandle) !std.fs.File.Stat { + return switch (self.kind) { + .file => self.file().stat(), + .dir => self.dir().stat(), + }; + } + + fn duplicate(self: HostHandle) !HostHandle { + const duplicated = if (builtin.os.tag == .windows) blk: { + const proc = windows.GetCurrentProcess(); + var dup_handle: windows.HANDLE = undefined; + if (kernel32.DuplicateHandle(proc, self.raw, proc, &dup_handle, 0, 0, windows.DUPLICATE_SAME_ACCESS) == 0) { + switch (windows.GetLastError()) { + .NOT_ENOUGH_MEMORY => return error.SystemResources, + .ACCESS_DENIED => return error.AccessDenied, + else => return error.Unexpected, + } + } + break :blk dup_handle; + } else try posix.dup(self.raw); + + return .{ + .raw = duplicated, + .kind = self.kind, + }; + } +}; + pub const Preopen = struct { wasi_fd: i32, path: []const u8, - host_fd: posix.fd_t, + host: HostHandle, + is_open: bool = true, }; /// Runtime file descriptor entry (for dynamically opened files). pub const FdEntry = struct { - host_fd: posix.fd_t, + host: HostHandle, is_open: bool = true, + append: bool = false, }; // ============================================================ @@ -215,11 +277,11 @@ pub const WasiContext = struct { self.environ_keys.deinit(self.alloc); self.environ_vals.deinit(self.alloc); for (self.preopens.items) |p| { - if (p.host_fd > 2) posix.close(p.host_fd); + if (p.is_open) p.host.close(); } self.preopens.deinit(self.alloc); for (self.fd_table.items) |entry| { - if (entry.is_open) posix.close(entry.host_fd); + if (entry.is_open) entry.host.close(); } self.fd_table.deinit(self.alloc); } @@ -233,29 +295,71 @@ pub const WasiContext = struct { try self.environ_vals.append(self.alloc, val); } - pub fn addPreopen(self: *WasiContext, wasi_fd: i32, path: []const u8, host_fd: posix.fd_t) !void { - try self.preopens.append(self.alloc, .{ .wasi_fd = wasi_fd, .path = path, .host_fd = host_fd }); + pub fn addPreopen(self: *WasiContext, wasi_fd: i32, path: []const u8, host_dir: std.fs.Dir) !void { + try self.preopens.append(self.alloc, .{ + .wasi_fd = wasi_fd, + .path = path, + .host = .{ + .raw = host_dir.fd, + .kind = .dir, + }, + }); + } + + pub fn addPreopenPath(self: *WasiContext, wasi_fd: i32, guest_path: []const u8, host_path: []const u8) !void { + const dir = if (std.fs.path.isAbsolute(host_path)) + try std.fs.openDirAbsolute(host_path, .{ .access_sub_paths = true, .iterate = true }) + else + try std.fs.cwd().openDir(host_path, .{ .access_sub_paths = true, .iterate = true }); + errdefer { + var owned = dir; + owned.close(); + } + try self.addPreopen(wasi_fd, guest_path, dir); } - fn getHostFd(self: *WasiContext, wasi_fd: i32) ?posix.fd_t { - // Standard FDs map directly - if (wasi_fd >= 0 and wasi_fd <= 2) return @intCast(wasi_fd); - // Preopened directories + fn getHostHandle(self: *WasiContext, wasi_fd: i32) ?HostHandle { for (self.preopens.items) |p| { - if (p.wasi_fd == wasi_fd) return p.host_fd; + if (p.wasi_fd == wasi_fd and p.is_open) return p.host; } - // Dynamic fd_table if (wasi_fd >= self.fd_base) { const idx: usize = @intCast(wasi_fd - self.fd_base); if (idx < self.fd_table.items.len and self.fd_table.items[idx].is_open) { - return self.fd_table.items[idx].host_fd; + return self.fd_table.items[idx].host; } } return null; } - /// Allocate a new WASI fd for a host file descriptor. - fn allocFd(self: *WasiContext, host_fd: posix.fd_t) !i32 { + fn getHostFd(self: *WasiContext, wasi_fd: i32) ?std.fs.File.Handle { + if (stdioFile(wasi_fd)) |file| return file.handle; + const host = self.getHostHandle(wasi_fd) orelse return null; + return host.raw; + } + + fn getFdEntry(self: *WasiContext, wasi_fd: i32) ?*FdEntry { + if (wasi_fd < self.fd_base) return null; + const idx: usize = @intCast(wasi_fd - self.fd_base); + if (idx >= self.fd_table.items.len or !self.fd_table.items[idx].is_open) return null; + return &self.fd_table.items[idx]; + } + + fn resolveFile(self: *WasiContext, wasi_fd: i32) ?std.fs.File { + return if (stdioFile(wasi_fd)) |file| + file + else if (self.getHostHandle(wasi_fd)) |host| + .{ .handle = host.raw } + else + null; + } + + fn resolveDir(self: *WasiContext, wasi_fd: i32) ?std.fs.Dir { + const host = self.getHostHandle(wasi_fd) orelse return null; + if (host.kind != .dir) return null; + return .{ .fd = host.raw }; + } + + fn allocFd(self: *WasiContext, host: HostHandle, append: bool) !i32 { // Compute fd_base lazily (after all preopens are added) if (self.fd_base == 0) { var max_fd: i32 = 2; // stdio @@ -267,24 +371,36 @@ pub const WasiContext = struct { // Reuse closed slot for (self.fd_table.items, 0..) |*entry, i| { if (!entry.is_open) { - entry.* = .{ .host_fd = host_fd }; + entry.* = .{ + .host = host, + .append = append, + }; return self.fd_base + @as(i32, @intCast(i)); } } // Append new entry const idx: i32 = @intCast(self.fd_table.items.len); - try self.fd_table.append(self.alloc, .{ .host_fd = host_fd }); + try self.fd_table.append(self.alloc, .{ + .host = host, + .append = append, + }); return self.fd_base + idx; } /// Close a dynamic fd. Returns false if fd is not a dynamic fd or already closed. fn closeFd(self: *WasiContext, wasi_fd: i32) bool { - if (wasi_fd < self.fd_base) return false; - const idx: usize = @intCast(wasi_fd - self.fd_base); - if (idx >= self.fd_table.items.len) return false; - if (!self.fd_table.items[idx].is_open) return false; - posix.close(self.fd_table.items[idx].host_fd); - self.fd_table.items[idx].is_open = false; + for (self.preopens.items) |*preopen| { + if (preopen.wasi_fd == wasi_fd and preopen.is_open) { + preopen.host.close(); + preopen.is_open = false; + return true; + } + } + + const entry = self.getFdEntry(wasi_fd) orelse return false; + entry.host.close(); + entry.is_open = false; + entry.append = false; return true; } }; @@ -312,6 +428,35 @@ inline fn hasCap(vm: *Vm, comptime field: std.meta.FieldEnum(Capabilities)) bool return @field(wasi.caps, @tagName(field)); } +fn stdioFile(fd: i32) ?std.fs.File { + return switch (fd) { + 0 => std.fs.File.stdin(), + 1 => std.fs.File.stdout(), + 2 => std.fs.File.stderr(), + else => null, + }; +} + +fn wasiFiletypeFromKind(kind: std.fs.File.Kind) u8 { + return switch (kind) { + .directory => @intFromEnum(Filetype.DIRECTORY), + .sym_link => @intFromEnum(Filetype.SYMBOLIC_LINK), + .file => @intFromEnum(Filetype.REGULAR_FILE), + .block_device => @intFromEnum(Filetype.BLOCK_DEVICE), + .character_device => @intFromEnum(Filetype.CHARACTER_DEVICE), + .named_pipe => @intFromEnum(Filetype.UNKNOWN), + .unix_domain_socket => @intFromEnum(Filetype.SOCKET_STREAM), + else => @intFromEnum(Filetype.UNKNOWN), + }; +} + +fn wasiNanos(value: i128) u64 { + const clamped: i64 = std.math.cast(i64, value) orelse blk: { + break :blk if (value < 0) std.math.minInt(i64) else std.math.maxInt(i64); + }; + return @bitCast(clamped); +} + // ============================================================ // WASI function implementations // ============================================================ @@ -467,9 +612,8 @@ pub fn fd_close(ctx: *anyopaque, _: usize) anyerror!void { const vm = getVm(ctx); const fd = vm.popOperandI32(); - // Don't close stdin/stdout/stderr - if (fd <= 2) { - try pushErrno(vm, .BADF); + if (fd >= 0 and fd <= 2) { + try pushErrno(vm, .SUCCESS); return; } @@ -484,13 +628,7 @@ pub fn fd_close(ctx: *anyopaque, _: usize) anyerror!void { return; } - // Fall back to preopened fd - if (wasi.getHostFd(fd)) |host_fd| { - posix.close(host_fd); - try pushErrno(vm, .SUCCESS); - } else { - try pushErrno(vm, .BADF); - } + try pushErrno(vm, .BADF); } /// fd_fdstat_get(fd: i32, stat_ptr: i32) -> errno @@ -508,10 +646,17 @@ pub fn fd_fdstat_get(ctx: *anyopaque, _: usize) anyerror!void { // Zero-fill then set filetype @memset(data[stat_ptr .. stat_ptr + 24], 0); - const filetype: u8 = switch (fd) { - 0, 1, 2 => @intFromEnum(Filetype.CHARACTER_DEVICE), - else => @intFromEnum(Filetype.DIRECTORY), // preopened dirs - }; + const filetype: u8 = if (fd >= 0 and fd <= 2) + @intFromEnum(Filetype.CHARACTER_DEVICE) + else if (getWasi(vm)) |wasi| + blk: { + const host = wasi.getHostHandle(fd) orelse break :blk @intFromEnum(Filetype.UNKNOWN); + if (host.kind == .dir) break :blk @intFromEnum(Filetype.DIRECTORY); + const stat = host.stat() catch break :blk @intFromEnum(Filetype.UNKNOWN); + break :blk wasiFiletypeFromKind(stat.kind); + } + else + @intFromEnum(Filetype.UNKNOWN); data[stat_ptr] = filetype; // Set full rights @@ -529,15 +674,15 @@ pub fn fd_filestat_get(ctx: *anyopaque, _: usize) anyerror!void { const fd = vm.popOperandI32(); const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + const file = if (wasi) |w| w.resolveFile(fd) orelse { try pushErrno(vm, .BADF); return; - } else if (fd >= 0 and fd <= 2) @intCast(fd) else { + } else if (stdioFile(fd)) |stdio| stdio else { try pushErrno(vm, .BADF); return; }; - const stat = posix.fstat(host_fd) catch |err| { + const stat = file.stat() catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -614,10 +759,10 @@ pub fn fd_read(ctx: *anyopaque, _: usize) anyerror!void { } const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + const file = if (wasi) |w| w.resolveFile(fd) orelse { try pushErrno(vm, .BADF); return; - } else if (fd >= 0 and fd <= 2) @intCast(fd) else { + } else if (stdioFile(fd)) |stdio| stdio else { try pushErrno(vm, .BADF); return; }; @@ -633,7 +778,7 @@ pub fn fd_read(ctx: *anyopaque, _: usize) anyerror!void { if (iov_ptr + iov_len > data.len) return error.OutOfBoundsMemoryAccess; const buf = data[iov_ptr .. iov_ptr + iov_len]; - const n = posix.read(host_fd, buf) catch |err| { + const n = file.read(buf) catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -656,7 +801,7 @@ pub fn fd_seek(ctx: *anyopaque, _: usize) anyerror!void { if (!hasCap(vm, .allow_read)) return pushErrno(vm, .ACCES); const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + const file = if (wasi) |w| w.resolveFile(fd) orelse { try pushErrno(vm, .BADF); return; } else if (fd >= 0 and fd <= 2) { @@ -668,21 +813,32 @@ pub fn fd_seek(ctx: *anyopaque, _: usize) anyerror!void { }; switch (@as(Whence, @enumFromInt(whence_val))) { - .SET => posix.lseek_SET(host_fd, @bitCast(offset)) catch |err| { + .SET => file.seekTo(@bitCast(offset)) catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }, - .CUR => posix.lseek_CUR(host_fd, offset) catch |err| { + .CUR => file.seekBy(offset) catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }, - .END => posix.lseek_END(host_fd, offset) catch |err| { - try pushErrno(vm, toWasiErrno(err)); - return; + .END => { + const end_pos = file.getEndPos() catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + const new_pos_i128 = @as(i128, @intCast(end_pos)) + offset; + if (new_pos_i128 < 0) { + try pushErrno(vm, .INVAL); + return; + } + file.seekTo(@intCast(new_pos_i128)) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; }, } - const new_pos = posix.lseek_CUR_get(host_fd) catch |err| { + const new_pos = file.getPos() catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -707,10 +863,10 @@ pub fn fd_write(ctx: *anyopaque, _: usize) anyerror!void { } const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + const file = if (wasi) |w| w.resolveFile(fd) orelse { try pushErrno(vm, .BADF); return; - } else if (fd >= 0 and fd <= 2) @intCast(fd) else { + } else if (stdioFile(fd)) |stdio| stdio else { try pushErrno(vm, .BADF); return; }; @@ -726,7 +882,22 @@ pub fn fd_write(ctx: *anyopaque, _: usize) anyerror!void { if (iov_ptr + iov_len > data.len) return error.OutOfBoundsMemoryAccess; const buf = data[iov_ptr .. iov_ptr + iov_len]; - const n = posix.write(host_fd, buf) catch |err| { + if (wasi) |w| { + if (w.getFdEntry(fd)) |entry| { + if (entry.append) { + const end_pos = file.getEndPos() catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + file.seekTo(end_pos) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + } + } + } + + const n = file.write(buf) catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -747,7 +918,7 @@ pub fn fd_tell(ctx: *anyopaque, _: usize) anyerror!void { if (!hasCap(vm, .allow_read)) return pushErrno(vm, .ACCES); const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + const file = if (wasi) |w| w.resolveFile(fd) orelse { try pushErrno(vm, .BADF); return; } else if (fd >= 0 and fd <= 2) { @@ -758,7 +929,7 @@ pub fn fd_tell(ctx: *anyopaque, _: usize) anyerror!void { return; }; - const cur = posix.lseek_CUR_get(host_fd) catch |err| { + const cur = file.getPos() catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -784,7 +955,7 @@ pub fn fd_readdir(ctx: *anyopaque, _: usize) anyerror!void { return; }; - const host_fd = wasi.getHostFd(fd) orelse { + var dir = wasi.resolveDir(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -793,7 +964,6 @@ pub fn fd_readdir(ctx: *anyopaque, _: usize) anyerror!void { const data = memory.memory(); if (buf_ptr + buf_len > data.len) return error.OutOfBoundsMemoryAccess; - var dir = std.fs.Dir{ .fd = host_fd }; var iter = dir.iterate(); // Skip entries up to cookie @@ -880,36 +1050,25 @@ fn wasiTimesToTimespec(fst_flags: u32, atim_ns: i64, mtim_ns: i64) [2]std.posix. return times; } -fn wasiFiletypeFromMode(mode: u32) u8 { - const S = std.posix.S; - if (S.ISDIR(mode)) return @intFromEnum(Filetype.DIRECTORY); - if (S.ISLNK(mode)) return @intFromEnum(Filetype.SYMBOLIC_LINK); - if (S.ISREG(mode)) return @intFromEnum(Filetype.REGULAR_FILE); - if (S.ISBLK(mode)) return @intFromEnum(Filetype.BLOCK_DEVICE); - if (S.ISCHR(mode)) return @intFromEnum(Filetype.CHARACTER_DEVICE); - return @intFromEnum(Filetype.UNKNOWN); +fn wasiTimestamp(fst_flags: u32, set_bit: u32, now_bit: u32, provided_ns: i64, fallback_ns: i128) i128 { + if (fst_flags & now_bit != 0) return std.time.nanoTimestamp(); + if (fst_flags & set_bit != 0) return provided_ns; + return fallback_ns; } -/// Write a WASI filestat struct (64 bytes) from a posix Stat to memory. -fn writeFilestat(memory: *WasmMemory, ptr: u32, stat: std.posix.Stat) !void { +/// Write a WASI filestat struct (64 bytes) from a portable file stat to memory. +fn writeFilestat(memory: *WasmMemory, ptr: u32, stat: std.fs.File.Stat) !void { const data = memory.memory(); if (ptr + 64 > data.len) return error.OutOfBoundsMemoryAccess; @memset(data[ptr .. ptr + 64], 0); // dev(u64)=0, ino(u64)=8, filetype(u8)=16, pad=17..23, nlink(u64)=24, size(u64)=32, atim(u64)=40, mtim(u64)=48, ctim(u64)=56 - try memory.write(u64, ptr, 8, @bitCast(@as(i64, @intCast(stat.ino)))); - data[ptr + 16] = wasiFiletypeFromMode(stat.mode); - try memory.write(u64, ptr, 24, @bitCast(@as(i64, @intCast(stat.nlink)))); - try memory.write(u64, ptr, 32, @bitCast(@as(i64, @intCast(stat.size)))); - // timestamps: sec * 1_000_000_000 + nsec (use methods for cross-platform) - const at = stat.atime(); - const mt = stat.mtime(); - const ct = stat.ctime(); - const atim: u64 = @bitCast(@as(i64, at.sec) * 1_000_000_000 + at.nsec); - const mtim: u64 = @bitCast(@as(i64, mt.sec) * 1_000_000_000 + mt.nsec); - const ctim: u64 = @bitCast(@as(i64, ct.sec) * 1_000_000_000 + ct.nsec); - try memory.write(u64, ptr, 40, atim); - try memory.write(u64, ptr, 48, mtim); - try memory.write(u64, ptr, 56, ctim); + try memory.write(u64, ptr, 8, @bitCast(@as(i64, @intCast(stat.inode)))); + data[ptr + 16] = wasiFiletypeFromKind(stat.kind); + try memory.write(u64, ptr, 24, 1); + try memory.write(u64, ptr, 32, stat.size); + try memory.write(u64, ptr, 40, wasiNanos(stat.atime)); + try memory.write(u64, ptr, 48, wasiNanos(stat.mtime)); + try memory.write(u64, ptr, 56, wasiNanos(stat.ctime)); } fn wasiFiletype(kind: std.fs.Dir.Entry.Kind) u8 { @@ -939,7 +1098,7 @@ pub fn path_filestat_get(ctx: *anyopaque, _: usize) anyerror!void { return; }; - const host_fd = wasi.getHostFd(fd) orelse { + var dir = wasi.resolveDir(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -949,9 +1108,8 @@ pub fn path_filestat_get(ctx: *anyopaque, _: usize) anyerror!void { if (path_ptr + path_len > data.len) return error.OutOfBoundsMemoryAccess; const path = data[path_ptr .. path_ptr + path_len]; - // dirflags bit 0: SYMLINK_FOLLOW - const nofollow: u32 = if (flags & 0x01 == 0) posix.AT.SYMLINK_NOFOLLOW else 0; - const stat = posix.fstatat(host_fd, path, nofollow) catch |err| { + _ = flags; + const stat = dir.statFile(path) catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -980,7 +1138,7 @@ pub fn path_open(ctx: *anyopaque, _: usize) anyerror!void { return; }; - const dir_fd = wasi.getHostFd(fd) orelse { + var dir = wasi.resolveDir(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -989,6 +1147,76 @@ pub fn path_open(ctx: *anyopaque, _: usize) anyerror!void { const data = memory.memory(); if (path_ptr + path_len > data.len) return error.OutOfBoundsMemoryAccess; const path = data[path_ptr .. path_ptr + path_len]; + const dir_fd = dir.fd; + + if (builtin.os.tag == .windows) { + const want_directory = oflags & 0x02 != 0; + const want_create = oflags & 0x01 != 0; + const want_exclusive = oflags & 0x04 != 0; + const want_truncate = oflags & 0x08 != 0; + const want_append = fdflags & 0x01 != 0; + + const new_fd = if (want_directory) blk: { + const opened_dir = dir.openDir(path, .{ + .access_sub_paths = true, + .iterate = true, + .no_follow = dirflags & 0x01 == 0, + }) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + errdefer { + var owned = opened_dir; + owned.close(); + } + break :blk wasi.allocFd(.{ + .raw = opened_dir.fd, + .kind = .dir, + }, false) catch { + var owned = opened_dir; + owned.close(); + try pushErrno(vm, .NOMEM); + return; + }; + } else blk: { + var opened_file = if (want_create) + dir.createFile(path, .{ + .read = true, + .truncate = want_truncate, + .exclusive = want_exclusive, + }) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + } + else + dir.openFile(path, .{ .mode = .read_write }) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + errdefer opened_file.close(); + + if (!want_create and want_truncate) { + opened_file.setEndPos(0) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + } + + break :blk wasi.allocFd(.{ + .raw = opened_file.handle, + .kind = .file, + }, want_append) catch { + opened_file.close(); + try pushErrno(vm, .NOMEM); + return; + }; + }; + + if (opened_fd_ptr + 4 > data.len) return error.OutOfBoundsMemoryAccess; + mem.writeInt(u32, data[opened_fd_ptr..][0..4], @bitCast(new_fd), .little); + try pushErrno(vm, .SUCCESS); + return; + } // Convert WASI oflags to posix flags var flags: posix.O = .{}; @@ -1023,7 +1251,10 @@ pub fn path_open(ctx: *anyopaque, _: usize) anyerror!void { try pushErrno(vm, toWasiErrno(err2)); return; }; - const new_fd = wasi.allocFd(ro_fd) catch { + const new_fd = wasi.allocFd(.{ + .raw = ro_fd, + .kind = if (flags.DIRECTORY) .dir else .file, + }, flags.APPEND) catch { posix.close(ro_fd); try pushErrno(vm, .NOMEM); return; @@ -1038,7 +1269,10 @@ pub fn path_open(ctx: *anyopaque, _: usize) anyerror!void { return; }; - const new_fd = wasi.allocFd(host_fd) catch { + const new_fd = wasi.allocFd(.{ + .raw = host_fd, + .kind = if (flags.DIRECTORY) .dir else .file, + }, flags.APPEND) catch { posix.close(host_fd); try pushErrno(vm, .NOMEM); return; @@ -1390,14 +1624,34 @@ pub fn fd_fdstat_set_flags(ctx: *anyopaque, _: usize) anyerror!void { const fd = vm.popOperandI32(); if (!hasCap(vm, .allow_write)) return pushErrno(vm, .ACCES); + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .NOSYS); + return; + }; + if (fd >= 0 and fd <= 2) { + try pushErrno(vm, .SUCCESS); + return; + } + if (wasi.getFdEntry(fd)) |entry| { + entry.append = fdflags & 0x01 != 0; + try pushErrno(vm, .SUCCESS); + return; + } + if (wasi.getHostHandle(fd) != null) { + try pushErrno(vm, .SUCCESS); + return; + } + try pushErrno(vm, .BADF); + return; + } + const wasi = getWasi(vm) orelse { try pushErrno(vm, .NOSYS); return; }; - const host_fd = wasi.getHostFd(fd) orelse if (fd >= 0 and fd <= 2) - @as(posix.fd_t, @intCast(fd)) - else { + const host_fd = wasi.getHostFd(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -1438,6 +1692,23 @@ pub fn fd_filestat_set_size(ctx: *anyopaque, _: usize) anyerror!void { return; } + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .NOSYS); + return; + }; + const file = wasi.resolveFile(fd) orelse { + try pushErrno(vm, .BADF); + return; + }; + file.setEndPos(@bitCast(size)) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + try pushErrno(vm, .SUCCESS); + return; + } + const wasi = getWasi(vm) orelse { try pushErrno(vm, .NOSYS); return; @@ -1463,14 +1734,36 @@ pub fn fd_filestat_set_times(ctx: *anyopaque, _: usize) anyerror!void { const fd = vm.popOperandI32(); if (!hasCap(vm, .allow_write)) return pushErrno(vm, .ACCES); + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .NOSYS); + return; + }; + const file = wasi.resolveFile(fd) orelse if (stdioFile(fd)) |stdio| stdio else { + try pushErrno(vm, .BADF); + return; + }; + const stat = file.stat() catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + file.updateTimes( + wasiTimestamp(fst_flags, 0x01, 0x02, atim_ns, stat.atime), + wasiTimestamp(fst_flags, 0x04, 0x08, mtim_ns, stat.mtime), + ) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + try pushErrno(vm, .SUCCESS); + return; + } + const wasi = getWasi(vm) orelse { try pushErrno(vm, .NOSYS); return; }; - const host_fd = wasi.getHostFd(fd) orelse if (fd >= 0 and fd <= 2) - @as(posix.fd_t, @intCast(fd)) - else { + const host_fd = wasi.getHostFd(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -1494,11 +1787,46 @@ pub fn fd_pread(ctx: *anyopaque, _: usize) anyerror!void { if (!hasCap(vm, .allow_read)) return pushErrno(vm, .ACCES); - const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .BADF); + return; + }; + const file = wasi.resolveFile(fd) orelse { + try pushErrno(vm, .BADF); + return; + }; + + const memory = try vm.getMemory(0); + const data = memory.memory(); + var total: u32 = 0; + var cur_offset: u64 = @bitCast(file_offset); + for (0..iovs_len) |i| { + const offset: u32 = @intCast(i * 8); + const iov_ptr = try memory.read(u32, iovs_ptr, offset); + const iov_len = try memory.read(u32, iovs_ptr, offset + 4); + if (iov_ptr + iov_len > data.len) return error.OutOfBoundsMemoryAccess; + + const buf = data[iov_ptr .. iov_ptr + iov_len]; + const n = file.pread(buf, cur_offset) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + total += @intCast(n); + cur_offset += n; + if (n < buf.len) break; + } + + try memory.write(u32, nread_ptr, 0, total); + try pushErrno(vm, .SUCCESS); + return; + } + + const wasi = getWasi(vm) orelse { try pushErrno(vm, .BADF); return; - } else if (fd >= 0 and fd <= 2) @intCast(fd) else { + }; + const host_fd: posix.fd_t = wasi.getHostFd(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -1539,11 +1867,46 @@ pub fn fd_pwrite(ctx: *anyopaque, _: usize) anyerror!void { if (!hasCap(vm, .allow_write)) return pushErrno(vm, .ACCES); - const wasi = getWasi(vm); - const host_fd: posix.fd_t = if (wasi) |w| w.getHostFd(fd) orelse { + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .BADF); + return; + }; + const file = wasi.resolveFile(fd) orelse { + try pushErrno(vm, .BADF); + return; + }; + + const memory = try vm.getMemory(0); + const data = memory.memory(); + var total: u32 = 0; + var cur_offset: u64 = @bitCast(file_offset); + for (0..iovs_len) |i| { + const offset: u32 = @intCast(i * 8); + const iov_ptr = try memory.read(u32, iovs_ptr, offset); + const iov_len = try memory.read(u32, iovs_ptr, offset + 4); + if (iov_ptr + iov_len > data.len) return error.OutOfBoundsMemoryAccess; + + const buf = data[iov_ptr .. iov_ptr + iov_len]; + const n = file.pwrite(buf, cur_offset) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + total += @intCast(n); + cur_offset += n; + if (n < buf.len) break; + } + + try memory.write(u32, nwritten_ptr, 0, total); + try pushErrno(vm, .SUCCESS); + return; + } + + const wasi = getWasi(vm) orelse { try pushErrno(vm, .BADF); return; - } else if (fd >= 0 and fd <= 2) @intCast(fd) else { + }; + const host_fd: posix.fd_t = wasi.getHostFd(fd) orelse { try pushErrno(vm, .BADF); return; }; @@ -1581,6 +1944,57 @@ pub fn fd_renumber(ctx: *anyopaque, _: usize) anyerror!void { if (!hasCap(vm, .allow_path)) return pushErrno(vm, .ACCES); + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .NOSYS); + return; + }; + + const from_host = wasi.getHostHandle(fd_from) orelse { + try pushErrno(vm, .BADF); + return; + }; + const append = if (wasi.getFdEntry(fd_from)) |entry| entry.append else false; + + _ = wasi.closeFd(fd_to); + const new_host = from_host.duplicate() catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + + _ = wasi.closeFd(fd_from); + + if (fd_to >= wasi.fd_base) { + const idx: usize = @intCast(fd_to - wasi.fd_base); + if (idx < wasi.fd_table.items.len) { + wasi.fd_table.items[idx] = .{ .host = new_host, .append = append }; + } else { + while (wasi.fd_table.items.len < idx) { + wasi.fd_table.append(wasi.alloc, .{ + .host = .{ .raw = undefined, .kind = .file }, + .is_open = false, + }) catch { + new_host.close(); + try pushErrno(vm, .NOMEM); + return; + }; + } + wasi.fd_table.append(wasi.alloc, .{ .host = new_host, .append = append }) catch { + new_host.close(); + try pushErrno(vm, .NOMEM); + return; + }; + } + } else { + new_host.close(); + try pushErrno(vm, .BADF); + return; + } + + try pushErrno(vm, .SUCCESS); + return; + } + const wasi = getWasi(vm) orelse { try pushErrno(vm, .NOSYS); return; @@ -1591,12 +2005,13 @@ pub fn fd_renumber(ctx: *anyopaque, _: usize) anyerror!void { try pushErrno(vm, .BADF); return; }; + const append = if (wasi.getFdEntry(fd_from)) |entry| entry.append else false; // Close destination fd if open _ = wasi.closeFd(fd_to); // Dup host fd and assign to fd_to slot - const new_host = posix.dup(from_host) catch |err| { + const new_host = (if (builtin.os.tag == .windows) unreachable else posix.dup(from_host)) catch |err| { try pushErrno(vm, toWasiErrno(err)); return; }; @@ -1608,17 +2023,26 @@ pub fn fd_renumber(ctx: *anyopaque, _: usize) anyerror!void { if (fd_to >= wasi.fd_base) { const idx: usize = @intCast(fd_to - wasi.fd_base); if (idx < wasi.fd_table.items.len) { - wasi.fd_table.items[idx] = .{ .host_fd = new_host }; + wasi.fd_table.items[idx] = .{ + .host = .{ .raw = new_host, .kind = .file }, + .append = append, + }; } else { // Extend table to fit while (wasi.fd_table.items.len < idx) { - wasi.fd_table.append(wasi.alloc, .{ .host_fd = 0, .is_open = false }) catch { + wasi.fd_table.append(wasi.alloc, .{ + .host = .{ .raw = undefined, .kind = .file }, + .is_open = false, + }) catch { posix.close(new_host); try pushErrno(vm, .NOMEM); return; }; } - wasi.fd_table.append(wasi.alloc, .{ .host_fd = new_host }) catch { + wasi.fd_table.append(wasi.alloc, .{ + .host = .{ .raw = new_host, .kind = .file }, + .append = append, + }) catch { posix.close(new_host); try pushErrno(vm, .NOMEM); return; @@ -1646,6 +2070,41 @@ pub fn path_filestat_set_times(ctx: *anyopaque, _: usize) anyerror!void { const fd = vm.popOperandI32(); if (!hasCap(vm, .allow_path)) return pushErrno(vm, .ACCES); + if (builtin.os.tag == .windows) { + const wasi = getWasi(vm) orelse { + try pushErrno(vm, .NOSYS); + return; + }; + var dir = wasi.resolveDir(fd) orelse { + try pushErrno(vm, .BADF); + return; + }; + + const memory = try vm.getMemory(0); + const data = memory.memory(); + if (path_ptr + path_len > data.len) return error.OutOfBoundsMemoryAccess; + const path = data[path_ptr .. path_ptr + path_len]; + if (dir.openFile(path, .{ .mode = .read_write })) |file| { + defer file.close(); + const stat = file.stat() catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + file.updateTimes( + wasiTimestamp(fst_flags, 0x01, 0x02, atim_ns, stat.atime), + wasiTimestamp(fst_flags, 0x04, 0x08, mtim_ns, stat.mtime), + ) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + try pushErrno(vm, .SUCCESS); + return; + } else |_| { + try pushErrno(vm, .NOSYS); + return; + } + } + const wasi = getWasi(vm) orelse { try pushErrno(vm, .NOSYS); return; @@ -1661,7 +2120,7 @@ pub fn path_filestat_set_times(ctx: *anyopaque, _: usize) anyerror!void { if (path_ptr + path_len > data.len) return error.OutOfBoundsMemoryAccess; const path = data[path_ptr .. path_ptr + path_len]; - const nofollow: u32 = if (flags & 0x01 == 0) posix.AT.SYMLINK_NOFOLLOW else 0; + const nofollow: u32 = if (builtin.os.tag == .windows) 0 else if (flags & 0x01 == 0) posix.AT.SYMLINK_NOFOLLOW else 0; var times = wasiTimesToTimespec(fst_flags, atim_ns, mtim_ns); // utimensat requires sentinel-terminated path @@ -1755,10 +2214,18 @@ pub fn path_symlink(ctx: *anyopaque, _: usize) anyerror!void { const old_path = data[old_path_ptr .. old_path_ptr + old_path_len]; const new_path = data[new_path_ptr .. new_path_ptr + new_path_len]; - posix.symlinkat(old_path, host_fd, new_path) catch |err| { - try pushErrno(vm, toWasiErrno(err)); - return; - }; + if (builtin.os.tag == .windows) { + var dir = std.fs.Dir{ .fd = host_fd }; + dir.symLink(old_path, new_path, .{}) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + } else { + posix.symlinkat(old_path, host_fd, new_path) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + } try pushErrno(vm, .SUCCESS); } @@ -1796,10 +2263,15 @@ pub fn path_link(ctx: *anyopaque, _: usize) anyerror!void { const old_path = data[old_path_ptr .. old_path_ptr + old_path_len]; const new_path = data[new_path_ptr .. new_path_ptr + new_path_len]; - posix.linkat(old_host_fd, old_path, new_host_fd, new_path, 0) catch |err| { - try pushErrno(vm, toWasiErrno(err)); + if (builtin.os.tag == .windows) { + try pushErrno(vm, .NOSYS); return; - }; + } else { + posix.linkat(old_host_fd, old_path, new_host_fd, new_path, 0) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + } try pushErrno(vm, .SUCCESS); } @@ -2018,6 +2490,8 @@ fn readTestFile(name: []const u8) ![]const u8 { } test "WASI — fd_write via 07_wasi_hello.wasm" { + if (builtin.os.tag == .windows) return error.SkipZigTest; + const alloc = testing.allocator; // Load and decode module @@ -2050,9 +2524,9 @@ test "WASI — fd_write via 07_wasi_hello.wasm" { defer posix.close(pipe[0]); // Redirect stdout to pipe write end - const saved_stdout = try posix.dup(1); + const saved_stdout = try posix.dup(@as(posix.fd_t, 1)); defer posix.close(saved_stdout); - try posix.dup2(pipe[1], 1); + try posix.dup2(pipe[1], @as(posix.fd_t, 1)); posix.close(pipe[1]); // Run _start @@ -2064,7 +2538,7 @@ test "WASI — fd_write via 07_wasi_hello.wasm" { }; // Restore stdout - try posix.dup2(saved_stdout, 1); + try posix.dup2(saved_stdout, @as(posix.fd_t, 1)); // Read captured output var buf: [256]u8 = undefined; @@ -2318,9 +2792,11 @@ test "WASI — path_open creates file and returns valid fd" { wasi_ctx.caps = Capabilities.all; instance.wasi = &wasi_ctx; - // Preopen /tmp as fd 3 - const tmp_fd = posix.openat(posix.AT.FDCWD, "/tmp", .{ .DIRECTORY = true }, 0) catch unreachable; - try wasi_ctx.addPreopen(3, "/tmp", tmp_fd); + var tmp = testing.tmpDir(.{}); + defer tmp.cleanup(); + const host_path = try std.fmt.allocPrint(alloc, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer alloc.free(host_path); + try wasi_ctx.addPreopenPath(3, "/tmp", host_path); try instance.instantiate(); @@ -2335,7 +2811,7 @@ test "WASI — path_open creates file and returns valid fd" { @memcpy(data[100 .. 100 + test_path.len], test_path); // Clean up test file if it exists - posix.unlinkat(tmp_fd, test_path, 0) catch {}; + tmp.dir.deleteFile(test_path) catch {}; // Push path_open args in signature order (stack: first pushed = bottom) // path_open(fd=3, dirflags=1, path_ptr=100, path_len, oflags=CREAT(1), @@ -2379,7 +2855,7 @@ test "WASI — path_open creates file and returns valid fd" { try testing.expectEqual(@as(u64, @intFromEnum(Errno.SUCCESS)), close_errno); // Clean up - posix.unlinkat(tmp_fd, test_path, 0) catch {}; + tmp.dir.deleteFile(test_path) catch {}; } test "WASI — fd_readdir lists directory entries" { @@ -2404,23 +2880,31 @@ test "WASI — fd_readdir lists directory entries" { wasi_ctx.caps = Capabilities.all; instance.wasi = &wasi_ctx; + var tmp = testing.tmpDir(.{ .iterate = true }); + defer tmp.cleanup(); + const host_path = try std.fmt.allocPrint(alloc, ".zig-cache/tmp/{s}", .{tmp.sub_path}); + defer alloc.free(host_path); + // Create a temp directory with known contents - const tmp_fd = posix.openat(posix.AT.FDCWD, "/tmp", .{ .DIRECTORY = true }, 0) catch unreachable; const test_dir = "zwasm_test_readdir"; - posix.mkdirat(tmp_fd, test_dir, 0o755) catch {}; - const dir_fd = posix.openat(tmp_fd, test_dir, .{ .DIRECTORY = true }, 0) catch unreachable; + tmp.dir.makeDir(test_dir) catch {}; + var dir_fd = try tmp.dir.openDir(test_dir, .{ .access_sub_paths = true }); + defer dir_fd.close(); // Create two files in the directory - const f1 = posix.openat(dir_fd, "afile.txt", .{ .CREAT = true, .ACCMODE = .RDWR }, 0o644) catch unreachable; - posix.close(f1); - const f2 = posix.openat(dir_fd, "bfile.txt", .{ .CREAT = true, .ACCMODE = .RDWR }, 0o644) catch unreachable; - posix.close(f2); + const f1 = try dir_fd.createFile("afile.txt", .{ .read = true }); + f1.close(); + const f2 = try dir_fd.createFile("bfile.txt", .{ .read = true }); + f2.close(); // Reopen dir fd for reading - const read_dir_fd = posix.openat(tmp_fd, test_dir, .{ .DIRECTORY = true }, 0) catch unreachable; - try wasi_ctx.addPreopen(3, "/tmp", tmp_fd); + const read_dir_fd = try tmp.dir.openDir(test_dir, .{ .access_sub_paths = true, .iterate = true }); + try wasi_ctx.addPreopenPath(3, "/tmp", host_path); // Put the dir fd in fd_table - const wasi_dir_fd = try wasi_ctx.allocFd(read_dir_fd); + const wasi_dir_fd = try wasi_ctx.allocFd(.{ + .raw = read_dir_fd.fd, + .kind = .dir, + }, false); try instance.instantiate(); @@ -2452,10 +2936,9 @@ test "WASI — fd_readdir lists directory entries" { try testing.expect(d_namlen < 256); // Clean up - posix.unlinkat(dir_fd, "afile.txt", 0) catch {}; - posix.unlinkat(dir_fd, "bfile.txt", 0) catch {}; - posix.close(dir_fd); - posix.unlinkat(tmp_fd, test_dir, posix.AT.REMOVEDIR) catch {}; + dir_fd.deleteFile("afile.txt") catch {}; + dir_fd.deleteFile("bfile.txt") catch {}; + tmp.dir.deleteDir(test_dir) catch {}; } test "WASI — registerAll for wasi_hello module" { diff --git a/src/x86.zig b/src/x86.zig index 2e2241ed..3a524bb9 100644 --- a/src/x86.zig +++ b/src/x86.zig @@ -4,7 +4,7 @@ //! x86_64 JIT compiler — compiles register IR to native machine code. //! Parallel to ARM64 backend in jit.zig. See D105 in .dev/decisions.md. //! -//! Register mapping (System V AMD64 ABI): +//! Register mapping (host AMD64 ABI): //! R12: regs_ptr (callee-saved, base of virtual register file) //! R13: mem_base (callee-saved, linear memory base pointer) //! R14: mem_size (callee-saved, linear memory size in bytes) @@ -23,7 +23,9 @@ //! //! JIT function signature (C calling convention): //! fn(regs: [*]u64, vm: *anyopaque, instance: *anyopaque) callconv(.c) u64 -//! Entry: RDI=regs, RSI=vm, RDX=instance. Returns: RAX=0 success. +//! SysV entry: RDI=regs, RSI=vm, RDX=instance +//! Win64 entry: RCX=regs, RDX=vm, R8=instance +//! Returns: RAX=0 success. const std = @import("std"); const builtin = @import("builtin"); @@ -40,6 +42,7 @@ const jit_mod = @import("jit.zig"); const JitCode = jit_mod.JitCode; const JitFn = jit_mod.JitFn; const vm_mod = @import("vm.zig"); +const platform = @import("platform.zig"); // ================================================================ // x86_64 register definitions @@ -1090,6 +1093,23 @@ fn vregToPhys(vreg: u16) ?Reg { }; } +fn abiRegsArg() Reg { + return if (builtin.os.tag == .windows) .rcx else .rdi; +} + +fn abiVmArg() Reg { + return if (builtin.os.tag == .windows) .rdx else .rsi; +} + +fn abiInstArg() Reg { + return if (builtin.os.tag == .windows) .r8 else .rdx; +} + +fn windowsCallFrameBytes(stack_arg_count: u32) u32 { + const bytes = 32 + stack_arg_count * 8; + return (bytes + 15) & ~@as(u32, 15); +} + /// Maximum virtual registers mappable to physical registers. const MAX_PHYS_REGS: u8 = 10; @@ -1350,13 +1370,15 @@ pub const Compiler = struct { // Save callee-saved registers Enc.push(&self.code, self.alloc, .rbx); Enc.push(&self.code, self.alloc, .rbp); + if (builtin.os.tag == .windows) { + Enc.push(&self.code, self.alloc, .rdi); + Enc.push(&self.code, self.alloc, .rsi); + } Enc.push(&self.code, self.alloc, .r12); Enc.push(&self.code, self.alloc, .r13); Enc.push(&self.code, self.alloc, .r14); Enc.push(&self.code, self.alloc, .r15); - // Align RSP to 16 bytes. Entry CALL pushed 8 bytes (return addr) + - // 6 callee-saved pushes = 56 bytes total, leaving RSP misaligned by 8. - // Sub 8 to restore 16-byte alignment required by System V ABI for CALLs. + // Align RSP to 16 bytes for nested CALLs. Enc.subImm32(&self.code, self.alloc, .rsp, 8); if (self.has_self_call) { @@ -1380,8 +1402,8 @@ pub const Compiler = struct { // Store marker [RSP] = 0 for self-call (epilogue discrimination) Enc.xorRegReg32(&self.code, self.alloc, SCRATCH2, SCRATCH2); Enc.storeDisp32(&self.code, self.alloc, .rsp, 0, SCRATCH2); - // RDI = callee regs pointer (set by caller) - Enc.movRegReg(&self.code, self.alloc, REGS_PTR, .rdi); + // ABI arg0 = callee regs pointer (set by caller) + Enc.movRegReg(&self.code, self.alloc, REGS_PTR, abiRegsArg()); // Skip memory cache load and shared setup — memory regs preserved // from caller (callee-saved R13/R14). vm/inst already in callee frame. } @@ -1400,11 +1422,11 @@ pub const Compiler = struct { Enc.patchRel32(self.code.items, jmp_shared_offset, self.currentOffset()); // --- Normal entry shared setup --- - Enc.movRegReg(&self.code, self.alloc, REGS_PTR, .rdi); // R12 = regs_ptr + Enc.movRegReg(&self.code, self.alloc, REGS_PTR, abiRegsArg()); // R12 = regs_ptr const vm_offset: i32 = (@as(i32, self.reg_count) + 2) * 8; const inst_offset: i32 = (@as(i32, self.reg_count) + 3) * 8; - Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, vm_offset, .rsi); - Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, inst_offset, .rdx); + Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, vm_offset, abiVmArg()); + Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, inst_offset, abiInstArg()); if (self.has_memory) { self.emitLoadMemCache(); @@ -1414,11 +1436,11 @@ pub const Compiler = struct { Enc.patchRel32(self.code.items, jmp_vreg_offset, self.currentOffset()); } else { // No self-call: normal setup directly - Enc.movRegReg(&self.code, self.alloc, REGS_PTR, .rdi); // R12 = regs_ptr + Enc.movRegReg(&self.code, self.alloc, REGS_PTR, abiRegsArg()); // R12 = regs_ptr const vm_offset: i32 = (@as(i32, self.reg_count) + 2) * 8; const inst_offset: i32 = (@as(i32, self.reg_count) + 3) * 8; - Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, vm_offset, .rsi); - Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, inst_offset, .rdx); + Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, vm_offset, abiVmArg()); + Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, inst_offset, abiInstArg()); if (self.has_memory) { self.emitLoadMemCache(); @@ -1509,6 +1531,10 @@ pub const Compiler = struct { Enc.pop(&self.code, self.alloc, .r14); Enc.pop(&self.code, self.alloc, .r13); Enc.pop(&self.code, self.alloc, .r12); + if (builtin.os.tag == .windows) { + Enc.pop(&self.code, self.alloc, .rsi); + Enc.pop(&self.code, self.alloc, .rdi); + } Enc.pop(&self.code, self.alloc, .rbp); Enc.pop(&self.code, self.alloc, .rbx); Enc.ret_(&self.code, self.alloc); @@ -1577,12 +1603,16 @@ pub const Compiler = struct { // Same callee-saved pushes as normal prologue (must match epilogue order) Enc.push(&self.code, self.alloc, .rbx); Enc.push(&self.code, self.alloc, .rbp); + if (builtin.os.tag == .windows) { + Enc.push(&self.code, self.alloc, .rdi); + Enc.push(&self.code, self.alloc, .rsi); + } Enc.push(&self.code, self.alloc, .r12); Enc.push(&self.code, self.alloc, .r13); Enc.push(&self.code, self.alloc, .r14); Enc.push(&self.code, self.alloc, .r15); - // Sub 8 to restore 16-byte alignment (6 pushes = 48 bytes + 8 from CALL = 56, +8 = 64) + // Sub 8 to restore 16-byte alignment for nested CALLs. Enc.subImm32(&self.code, self.alloc, .rsp, 8); // Marker [RSP] = 1 (normal entry — epilogue does full restore) @@ -1591,14 +1621,14 @@ pub const Compiler = struct { Enc.storeDisp32(&self.code, self.alloc, .rsp, 0, SCRATCH2); } - // R12 = REGS_PTR (arg0 = RDI) - Enc.movRegReg(&self.code, self.alloc, REGS_PTR, .rdi); + // R12 = REGS_PTR (arg0 in host C ABI) + Enc.movRegReg(&self.code, self.alloc, REGS_PTR, abiRegsArg()); - // Store VM pointer (RSI) and Instance pointer (RDX) to register file slots + // Store VM and Instance pointers to register file slots const vm_disp: i32 = (@as(i32, self.reg_count) + 2) * 8; const inst_disp: i32 = (@as(i32, self.reg_count) + 3) * 8; - Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, vm_disp, .rsi); - Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, inst_disp, .rdx); + Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, vm_disp, abiVmArg()); + Enc.storeDisp32(&self.code, self.alloc, REGS_PTR, inst_disp, abiInstArg()); // Load memory cache (if function uses memory) if (self.has_memory) { @@ -1650,20 +1680,43 @@ pub const Compiler = struct { } } + fn emitWindowsCallSetup(self: *Compiler, stack_arg_count: u32) u32 { + const frame_bytes = windowsCallFrameBytes(stack_arg_count); + Enc.subImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + return frame_bytes; + } + + fn emitWindowsCallArg(self: *Compiler, stack_index: u32, src: Reg) void { + const disp: i32 = @intCast(32 + stack_index * 8); + Enc.storeDisp32(&self.code, self.alloc, .rsp, disp, src); + } + /// Load memory cache: call jitGetMemInfo(instance, ®s[reg_count]) then /// load MEM_BASE and MEM_SIZE from the output slots. fn emitLoadMemCache(self: *Compiler) void { - // System V ABI: RDI=arg0 (instance), RSI=arg1 (®s[reg_count]) - self.emitLoadInstPtr(.rdi); - // RSI = address of regs[reg_count] = REGS_PTR + reg_count*8 const out_disp: i32 = @as(i32, self.reg_count) * 8; - Enc.movRegReg(&self.code, self.alloc, .rsi, REGS_PTR); - if (out_disp > 0) { - Enc.addImm32(&self.code, self.alloc, .rsi, out_disp); + if (builtin.os.tag == .windows) { + self.emitLoadInstPtr(.rcx); + Enc.movRegReg(&self.code, self.alloc, .rdx, REGS_PTR); + if (out_disp > 0) { + Enc.addImm32(&self.code, self.alloc, .rdx, out_disp); + } + const frame_bytes = self.emitWindowsCallSetup(0); + self.emitLoadImm64(SCRATCH, self.mem_info_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + } else { + // System V ABI: RDI=arg0 (instance), RSI=arg1 (®s[reg_count]) + self.emitLoadInstPtr(.rdi); + // RSI = address of regs[reg_count] = REGS_PTR + reg_count*8 + Enc.movRegReg(&self.code, self.alloc, .rsi, REGS_PTR); + if (out_disp > 0) { + Enc.addImm32(&self.code, self.alloc, .rsi, out_disp); + } + // CALL jitGetMemInfo + self.emitLoadImm64(SCRATCH, self.mem_info_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); } - // CALL jitGetMemInfo - self.emitLoadImm64(SCRATCH, self.mem_info_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); // Load results: MEM_BASE = regs[reg_count], MEM_SIZE = regs[reg_count+1] Enc.loadDisp32(&self.code, self.alloc, MEM_BASE, REGS_PTR, out_disp); Enc.loadDisp32(&self.code, self.alloc, MEM_SIZE, REGS_PTR, out_disp + 8); @@ -1758,12 +1811,20 @@ pub const Compiler = struct { /// global.get: call jitGlobalGet(instance, idx) → u64 fn emitGlobalGet(self: *Compiler, instr: RegInstr) void { self.spillCallerSaved(); - // System V ABI: RDI=instance, ESI=global_idx - self.emitLoadInstPtr(.rdi); - self.emitLoadImm32(.rsi, @truncate(instr.operand)); - // CALL jitGlobalGet - self.emitLoadImm64(SCRATCH, self.global_get_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); + if (builtin.os.tag == .windows) { + self.emitLoadInstPtr(.rcx); + self.emitLoadImm32(.rdx, @truncate(instr.operand)); + const frame_bytes = self.emitWindowsCallSetup(0); + self.emitLoadImm64(SCRATCH, self.global_get_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + } else { + // System V ABI: RDI=instance, ESI=global_idx + self.emitLoadInstPtr(.rdi); + self.emitLoadImm32(.rsi, @truncate(instr.operand)); + self.emitLoadImm64(SCRATCH, self.global_get_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + } // Result in RAX (u64) self.reloadCallerSaved(); self.storeVreg(instr.rd, SCRATCH); // SCRATCH = RAX = result @@ -1772,16 +1833,25 @@ pub const Compiler = struct { /// global.set: call jitGlobalSet(instance, idx, val) fn emitGlobalSet(self: *Compiler, instr: RegInstr) void { self.spillCallerSaved(); - // Load value to RDX FIRST — before clobbering RDI/RSI with call args. - // The value vreg may be in RDI or RSI (caller-saved, still valid after spill). const val_reg = self.getOrLoad(instr.rd, SCRATCH); - Enc.movRegReg(&self.code, self.alloc, .rdx, val_reg); - // System V ABI: RDI=instance, ESI=global_idx, RDX=value (already set) - self.emitLoadInstPtr(.rdi); - self.emitLoadImm32(.rsi, @truncate(instr.operand)); - // CALL jitGlobalSet - self.emitLoadImm64(SCRATCH, self.global_set_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); + if (builtin.os.tag == .windows) { + Enc.movRegReg(&self.code, self.alloc, .r8, val_reg); + self.emitLoadInstPtr(.rcx); + self.emitLoadImm32(.rdx, @truncate(instr.operand)); + const frame_bytes = self.emitWindowsCallSetup(0); + self.emitLoadImm64(SCRATCH, self.global_set_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + } else { + // Load value to RDX FIRST — before clobbering RDI/RSI with call args. + // The value vreg may be in RDI or RSI (caller-saved, still valid after spill). + Enc.movRegReg(&self.code, self.alloc, .rdx, val_reg); + // System V ABI: RDI=instance, ESI=global_idx, RDX=value (already set) + self.emitLoadInstPtr(.rdi); + self.emitLoadImm32(.rsi, @truncate(instr.operand)); + self.emitLoadImm64(SCRATCH, self.global_set_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + } self.reloadCallerSaved(); self.scratch_vreg = null; } @@ -1807,36 +1877,48 @@ pub const Compiler = struct { } } - // 2. Set up trampoline args (System V AMD64 ABI: RDI, RSI, RDX, RCX, R8, R9): - // RDI=vm, RSI=instance, RDX=regs, ECX=func_idx, R8D=rd, R9=data_word - // data2 goes on the stack (7th argument) - self.emitLoadVmPtr(.rdi); - self.emitLoadInstPtr(.rsi); - Enc.movRegReg(&self.code, self.alloc, .rdx, REGS_PTR); - self.emitLoadImm32(.rcx, func_idx); - self.emitLoadImm32(.r8, @as(u32, rd)); - // Pack data word as u64 into R9 const data_u64 = packDataWord(data); - self.emitLoadImm64(.r9, data_u64); - // data2 as 7th argument: push onto stack - if (data2) |d2| { - const d2_u64 = packDataWord(d2); - self.emitLoadImm64(SCRATCH, d2_u64); + if (builtin.os.tag == .windows) { + // Win64 ABI: RCX, RDX, R8, R9 then stack args after 32-byte shadow space. + self.emitLoadVmPtr(.rcx); + self.emitLoadInstPtr(.rdx); + Enc.movRegReg(&self.code, self.alloc, .r8, REGS_PTR); + self.emitLoadImm32(.r9, func_idx); + + const frame_bytes = self.emitWindowsCallSetup(3); + self.emitLoadImm32(SCRATCH2, @as(u32, rd)); + self.emitWindowsCallArg(0, SCRATCH2); + self.emitLoadImm64(SCRATCH2, data_u64); + self.emitWindowsCallArg(1, SCRATCH2); + if (data2) |d2| { + self.emitLoadImm64(SCRATCH2, packDataWord(d2)); + } else { + Enc.xorRegReg32(&self.code, self.alloc, SCRATCH2, SCRATCH2); + } + self.emitWindowsCallArg(2, SCRATCH2); + + self.emitLoadImm64(SCRATCH, self.trampoline_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); } else { - Enc.xorRegReg32(&self.code, self.alloc, SCRATCH, SCRATCH); + // System V AMD64 ABI: RDI, RSI, RDX, RCX, R8, R9; data2 on stack. + self.emitLoadVmPtr(.rdi); + self.emitLoadInstPtr(.rsi); + Enc.movRegReg(&self.code, self.alloc, .rdx, REGS_PTR); + self.emitLoadImm32(.rcx, func_idx); + self.emitLoadImm32(.r8, @as(u32, rd)); + self.emitLoadImm64(.r9, data_u64); + if (data2) |d2| { + self.emitLoadImm64(SCRATCH, packDataWord(d2)); + } else { + Enc.xorRegReg32(&self.code, self.alloc, SCRATCH, SCRATCH); + } + Enc.subImm32(&self.code, self.alloc, .rsp, 8); + Enc.push(&self.code, self.alloc, SCRATCH); + self.emitLoadImm64(SCRATCH, self.trampoline_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, 16); } - // Alignment padding: RSP is 16-aligned after prologue; one push would - // leave RSP at 8 mod 16, violating the ABI before CALL. - // Sub 8 first so that push + sub = 16 bytes, keeping RSP 16-aligned. - Enc.subImm32(&self.code, self.alloc, .rsp, 8); - Enc.push(&self.code, self.alloc, SCRATCH); // 7th arg on stack - - // 3. CALL trampoline - self.emitLoadImm64(SCRATCH, self.trampoline_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); - - // 4. Clean up stack (remove 7th arg + alignment padding = 16 bytes) - Enc.addImm32(&self.code, self.alloc, .rsp, 16); // 5. Check error (RAX != 0 → error) Enc.testRegReg(&self.code, self.alloc, .rax, .rax); @@ -1875,36 +1957,56 @@ pub const Compiler = struct { } self.spillVreg(instr.rs1); - // 2. System V AMD64: RDI=vm, RSI=instance, RDX=regs, ECX=type_idx_table_idx, - // R8D=result_reg, R9=data_word, stack[0]=data2_word, stack[1]=elem_idx - self.emitLoadVmPtr(.rdi); - self.emitLoadInstPtr(.rsi); - Enc.movRegReg(&self.code, self.alloc, .rdx, REGS_PTR); - self.emitLoadImm32(.rcx, instr.operand); - self.emitLoadImm32(.r8, @as(u32, instr.rd)); const data_u64 = packDataWord(data); - self.emitLoadImm64(.r9, data_u64); - - // Push elem_idx (8th arg) first, then data2 (7th arg) — right to left - // Load elem_idx from regs[instr.rs1] const elem_disp: i32 = @as(i32, @intCast(instr.rs1)) * 8; - Enc.loadDisp32(&self.code, self.alloc, SCRATCH, REGS_PTR, elem_disp); - Enc.push(&self.code, self.alloc, SCRATCH); // 8th arg + if (builtin.os.tag == .windows) { + self.emitLoadVmPtr(.rcx); + self.emitLoadInstPtr(.rdx); + Enc.movRegReg(&self.code, self.alloc, .r8, REGS_PTR); + self.emitLoadImm32(.r9, instr.operand); + + const frame_bytes = self.emitWindowsCallSetup(4); + self.emitLoadImm32(SCRATCH2, @as(u32, instr.rd)); + self.emitWindowsCallArg(0, SCRATCH2); + self.emitLoadImm64(SCRATCH2, data_u64); + self.emitWindowsCallArg(1, SCRATCH2); + if (data2) |d2| { + self.emitLoadImm64(SCRATCH2, packDataWord(d2)); + } else { + Enc.xorRegReg32(&self.code, self.alloc, SCRATCH2, SCRATCH2); + } + self.emitWindowsCallArg(2, SCRATCH2); + Enc.loadDisp32(&self.code, self.alloc, SCRATCH2, REGS_PTR, elem_disp); + self.emitWindowsCallArg(3, SCRATCH2); - if (data2) |d2| { - const d2_u64 = packDataWord(d2); - self.emitLoadImm64(SCRATCH, d2_u64); + self.emitLoadImm64(SCRATCH, self.call_indirect_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); } else { - Enc.xorRegReg32(&self.code, self.alloc, SCRATCH, SCRATCH); - } - Enc.push(&self.code, self.alloc, SCRATCH); // 7th arg + // System V AMD64: RDI=vm, RSI=instance, RDX=regs, ECX=type_idx_table_idx, + // R8D=result_reg, R9=data_word, stack[0]=data2_word, stack[1]=elem_idx + self.emitLoadVmPtr(.rdi); + self.emitLoadInstPtr(.rsi); + Enc.movRegReg(&self.code, self.alloc, .rdx, REGS_PTR); + self.emitLoadImm32(.rcx, instr.operand); + self.emitLoadImm32(.r8, @as(u32, instr.rd)); + self.emitLoadImm64(.r9, data_u64); + + // Push elem_idx (8th arg) first, then data2 (7th arg) — right to left + Enc.loadDisp32(&self.code, self.alloc, SCRATCH, REGS_PTR, elem_disp); + Enc.push(&self.code, self.alloc, SCRATCH); // 8th arg - // 3. CALL trampoline - self.emitLoadImm64(SCRATCH, self.call_indirect_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); + if (data2) |d2| { + self.emitLoadImm64(SCRATCH, packDataWord(d2)); + } else { + Enc.xorRegReg32(&self.code, self.alloc, SCRATCH, SCRATCH); + } + Enc.push(&self.code, self.alloc, SCRATCH); // 7th arg - // 4. Clean up stack (2 pushed args = 16 bytes) - Enc.addImm32(&self.code, self.alloc, .rsp, 16); + self.emitLoadImm64(SCRATCH, self.call_indirect_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, 16); + } // 5. Check error Enc.testRegReg(&self.code, self.alloc, .rax, .rax); @@ -1972,21 +2074,22 @@ pub const Compiler = struct { Enc.addImm32(&self.code, self.alloc, SCRATCH, 1); Enc.storeDisp32(&self.code, self.alloc, SCRATCH2, cd_offset, SCRATCH); - // 5. Compute callee REGS_PTR in RDI: R12 + needed_bytes - Enc.movRegReg(&self.code, self.alloc, .rdi, REGS_PTR); - Enc.addImm32(&self.code, self.alloc, .rdi, @intCast(needed_bytes)); + // 5. Compute callee REGS_PTR in arg0 register: caller REGS_PTR + needed_bytes + const callee_regs_arg = abiRegsArg(); + Enc.movRegReg(&self.code, self.alloc, callee_regs_arg, REGS_PTR); + Enc.addImm32(&self.code, self.alloc, callee_regs_arg, @intCast(needed_bytes)); // 6. Copy args from caller's physical regs/memory to callee frame - self.emitArgCopyDirect(.rdi, data.rd, 0); - if (n_args > 1) self.emitArgCopyDirect(.rdi, data.rs1, 8); - if (n_args > 2) self.emitArgCopyDirect(.rdi, data.rs2_field, 16); - if (n_args > 3) self.emitArgCopyDirect(.rdi, @truncate(data.operand), 24); + self.emitArgCopyDirect(callee_regs_arg, data.rd, 0); + if (n_args > 1) self.emitArgCopyDirect(callee_regs_arg, data.rs1, 8); + if (n_args > 2) self.emitArgCopyDirect(callee_regs_arg, data.rs2_field, 16); + if (n_args > 3) self.emitArgCopyDirect(callee_regs_arg, @truncate(data.operand), 24); if (n_args > 4) { if (data2) |d2| { - if (n_args > 4) self.emitArgCopyDirect(.rdi, d2.rd, 32); - if (n_args > 5) self.emitArgCopyDirect(.rdi, d2.rs1, 40); - if (n_args > 6) self.emitArgCopyDirect(.rdi, d2.rs2_field, 48); - if (n_args > 7) self.emitArgCopyDirect(.rdi, @truncate(d2.operand), 56); + if (n_args > 4) self.emitArgCopyDirect(callee_regs_arg, d2.rd, 32); + if (n_args > 5) self.emitArgCopyDirect(callee_regs_arg, d2.rs1, 40); + if (n_args > 6) self.emitArgCopyDirect(callee_regs_arg, d2.rs2_field, 48); + if (n_args > 7) self.emitArgCopyDirect(callee_regs_arg, @truncate(d2.operand), 56); } } @@ -1995,7 +2098,7 @@ pub const Compiler = struct { Enc.xorRegReg32(&self.code, self.alloc, SCRATCH, SCRATCH); for (n_args..self.local_count) |i| { const offset: i32 = @intCast(i * 8); - Enc.storeDisp32(&self.code, self.alloc, .rdi, offset, SCRATCH); + Enc.storeDisp32(&self.code, self.alloc, callee_regs_arg, offset, SCRATCH); } } @@ -2003,9 +2106,9 @@ pub const Compiler = struct { const vm_slot: i32 = (@as(i32, self.reg_count) + 2) * 8; const inst_slot: i32 = (@as(i32, self.reg_count) + 3) * 8; Enc.loadDisp32(&self.code, self.alloc, SCRATCH, REGS_PTR, vm_slot); - Enc.storeDisp32(&self.code, self.alloc, .rdi, vm_slot, SCRATCH); + Enc.storeDisp32(&self.code, self.alloc, callee_regs_arg, vm_slot, SCRATCH); Enc.loadDisp32(&self.code, self.alloc, SCRATCH, REGS_PTR, inst_slot); - Enc.storeDisp32(&self.code, self.alloc, .rdi, inst_slot, SCRATCH); + Enc.storeDisp32(&self.code, self.alloc, callee_regs_arg, inst_slot, SCRATCH); // 9. CALL self_call_entry (direct, no trampoline) // Emit CALL rel32 — target is self_call_entry_offset in the code buffer. @@ -2096,14 +2199,21 @@ pub const Compiler = struct { /// Emit memory.grow via trampoline call. fn emitMemGrow(self: *Compiler, instr: RegInstr) void { self.spillCallerSaved(); - // Load pages BEFORE clobbering RDI — value may be in RDI (vreg 4) const pages_reg = self.getOrLoad(instr.rs1, SCRATCH); - Enc.movRegReg32(&self.code, self.alloc, .rsi, pages_reg); - // System V: RDI=instance, RSI=pages (already set) - self.emitLoadInstPtr(.rdi); - // CALL jitMemGrow - self.emitLoadImm64(SCRATCH, self.mem_grow_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); + if (builtin.os.tag == .windows) { + Enc.movRegReg32(&self.code, self.alloc, .rdx, pages_reg); + self.emitLoadInstPtr(.rcx); + const frame_bytes = self.emitWindowsCallSetup(0); + self.emitLoadImm64(SCRATCH, self.mem_grow_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + } else { + // Load pages BEFORE clobbering RDI — value may be in RDI (vreg 4) + Enc.movRegReg32(&self.code, self.alloc, .rsi, pages_reg); + self.emitLoadInstPtr(.rdi); + self.emitLoadImm64(SCRATCH, self.mem_grow_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + } // Result in EAX (u32): old_pages or 0xFFFFFFFF // Store result to regs[rd] immediately (before RAX is clobbered) const rd_disp: i32 = @as(i32, instr.rd) * 8; @@ -2126,14 +2236,24 @@ pub const Compiler = struct { self.spillVreg(instr.rd); self.spillVreg(instr.rs1); self.spillVreg(instr.rs2()); - // System V: RDI=instance, ESI=dst, EDX=val, ECX=n - Enc.loadDisp32(&self.code, self.alloc, .rsi, REGS_PTR, @as(i32, instr.rd) * 8); - Enc.loadDisp32(&self.code, self.alloc, .rdx, REGS_PTR, @as(i32, instr.rs1) * 8); - Enc.loadDisp32(&self.code, self.alloc, .rcx, REGS_PTR, @as(i32, instr.rs2()) * 8); - self.emitLoadInstPtr(.rdi); - // CALL jitMemFill - self.emitLoadImm64(SCRATCH, self.mem_fill_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); + if (builtin.os.tag == .windows) { + self.emitLoadInstPtr(.rcx); + Enc.loadDisp32(&self.code, self.alloc, .rdx, REGS_PTR, @as(i32, instr.rd) * 8); + Enc.loadDisp32(&self.code, self.alloc, .r8, REGS_PTR, @as(i32, instr.rs1) * 8); + Enc.loadDisp32(&self.code, self.alloc, .r9, REGS_PTR, @as(i32, instr.rs2()) * 8); + const frame_bytes = self.emitWindowsCallSetup(0); + self.emitLoadImm64(SCRATCH, self.mem_fill_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + } else { + // System V: RDI=instance, ESI=dst, EDX=val, ECX=n + Enc.loadDisp32(&self.code, self.alloc, .rsi, REGS_PTR, @as(i32, instr.rd) * 8); + Enc.loadDisp32(&self.code, self.alloc, .rdx, REGS_PTR, @as(i32, instr.rs1) * 8); + Enc.loadDisp32(&self.code, self.alloc, .rcx, REGS_PTR, @as(i32, instr.rs2()) * 8); + self.emitLoadInstPtr(.rdi); + self.emitLoadImm64(SCRATCH, self.mem_fill_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + } // Check error (EAX != 0 → OOB) Enc.testRegReg32(&self.code, self.alloc, .rax, .rax); self.emitCondError(.ne, 6); // OutOfBoundsMemoryAccess @@ -2148,14 +2268,24 @@ pub const Compiler = struct { self.spillVreg(instr.rd); self.spillVreg(instr.rs1); self.spillVreg(instr.rs2()); - // System V: RDI=instance, ESI=dst, EDX=src, ECX=n - Enc.loadDisp32(&self.code, self.alloc, .rsi, REGS_PTR, @as(i32, instr.rd) * 8); - Enc.loadDisp32(&self.code, self.alloc, .rdx, REGS_PTR, @as(i32, instr.rs1) * 8); - Enc.loadDisp32(&self.code, self.alloc, .rcx, REGS_PTR, @as(i32, instr.rs2()) * 8); - self.emitLoadInstPtr(.rdi); - // CALL jitMemCopy - self.emitLoadImm64(SCRATCH, self.mem_copy_addr); - Enc.callReg(&self.code, self.alloc, SCRATCH); + if (builtin.os.tag == .windows) { + self.emitLoadInstPtr(.rcx); + Enc.loadDisp32(&self.code, self.alloc, .rdx, REGS_PTR, @as(i32, instr.rd) * 8); + Enc.loadDisp32(&self.code, self.alloc, .r8, REGS_PTR, @as(i32, instr.rs1) * 8); + Enc.loadDisp32(&self.code, self.alloc, .r9, REGS_PTR, @as(i32, instr.rs2()) * 8); + const frame_bytes = self.emitWindowsCallSetup(0); + self.emitLoadImm64(SCRATCH, self.mem_copy_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + Enc.addImm32(&self.code, self.alloc, .rsp, @intCast(frame_bytes)); + } else { + // System V: RDI=instance, ESI=dst, EDX=src, ECX=n + Enc.loadDisp32(&self.code, self.alloc, .rsi, REGS_PTR, @as(i32, instr.rd) * 8); + Enc.loadDisp32(&self.code, self.alloc, .rdx, REGS_PTR, @as(i32, instr.rs1) * 8); + Enc.loadDisp32(&self.code, self.alloc, .rcx, REGS_PTR, @as(i32, instr.rs2()) * 8); + self.emitLoadInstPtr(.rdi); + self.emitLoadImm64(SCRATCH, self.mem_copy_addr); + Enc.callReg(&self.code, self.alloc, SCRATCH); + } // Check error Enc.testRegReg32(&self.code, self.alloc, .rax, .rax); self.emitCondError(.ne, 6); @@ -2951,29 +3081,20 @@ pub const Compiler = struct { const page_size = std.heap.page_size_min; const buf_size = std.mem.alignForward(usize, code_size, page_size); - const PROT = std.posix.PROT; - const buf = std.posix.mmap( - null, - buf_size, - PROT.READ | PROT.WRITE, - .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, - -1, - 0, - ) catch return null; - const aligned_buf: []align(std.heap.page_size_min) u8 = @alignCast(buf); + const aligned_buf = platform.allocatePages(buf_size, .read_write) catch return null; @memcpy(aligned_buf[0..code_size], self.code.items); // W^X transition - std.posix.mprotect(aligned_buf, PROT.READ | PROT.EXEC) catch { - std.posix.munmap(aligned_buf); + platform.protectPages(aligned_buf, .read_exec) catch { + platform.freePages(aligned_buf); return null; }; // x86_64 has coherent I/D caches — no icache flush needed. const jit_code = self.alloc.create(JitCode) catch { - std.posix.munmap(aligned_buf); + platform.freePages(aligned_buf); return null; }; jit_code.* = .{ diff --git a/test/e2e/convert.py b/test/e2e/convert.py new file mode 100644 index 00000000..84477808 --- /dev/null +++ b/test/e2e/convert.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +WAST_DIR = ROOT / "test" / "e2e" / "wast" +JSON_DIR = ROOT / "test" / "e2e" / "json" +SKIP_FILE = ROOT / "test" / "e2e" / "skip.txt" + +BATCH1 = [ + "add.wast", "div-rem.wast", "mul16-negative.wast", "wide-arithmetic.wast", + "control-flow.wast", "br-table-fuzzbug.wast", "simple-unreachable.wast", + "misc_traps.wast", "stack_overflow.wast", "no-panic.wast", "no-panic-on-invalid.wast", + "memory-copy.wast", "imported-memory-copy.wast", "partial-init-memory-segment.wast", + "call_indirect.wast", "many-results.wast", "many-return-values.wast", + "export-large-signature.wast", "func-400-params.wast", "table_copy.wast", + "table_copy_on_imported_tables.wast", "elem_drop.wast", "elem-ref-null.wast", + "table_grow_with_funcref.wast", "linking-errors.wast", "empty.wast", +] + +BATCH2 = [ + "f64-copysign.wast", "float-round-doesnt-load-too-much.wast", + "int-to-float-splat.wast", "sink-float-but-dont-trap.wast", + "externref-id-function.wast", "externref-segment.wast", + "mutable_externref_globals.wast", "simple_ref_is_null.wast", + "externref-table-dropped-segment-issue-8281.wast", "bit-and-conditions.wast", + "no-opt-panic-dividing-by-zero.wast", "partial-init-table-segment.wast", + "many_table_gets_lead_to_gc.wast", "no-mixup-stack-maps.wast", "rs2wasm-add-func.wast", +] + +BATCH3 = [ + "embenchen_fannkuch.wast", "embenchen_fasta.wast", "embenchen_ifs.wast", + "embenchen_primes.wast", "rust_fannkuch.wast", "fib.wast", "issue1809.wast", + "issue4840.wast", "issue4857.wast", "issue4890.wast", "issue6562.wast", + "issue694.wast", "issue11748.wast", "issue12318.wast", +] + +BATCH3_WAT: list[str] = [] + +BATCH4_SIMD = [ + "simd/cvt-from-uint.wast", "simd/edge-of-memory.wast", "simd/unaligned-load.wast", + "simd/load_splat_out_of_bounds.wast", "simd/v128-select.wast", + "simd/replace-lane-preserve.wast", "simd/almost-extmul.wast", + "simd/interesting-float-splat.wast", "simd/issue4807.wast", + "simd/issue6725-no-egraph-panic.wast", "simd/issue_3173_select_v128.wast", + "simd/issue_3327_bnot_lowering.wast", "simd/spillslot-size-fuzzbug.wast", + "simd/sse-cannot-fold-unaligned-loads.wast", +] + +BATCH5_PROPOSALS = [ + "function-references/call_indirect.wast", "function-references/table_fill.wast", + "function-references/table_get.wast", "function-references/table_grow.wast", + "function-references/table_set.wast", "tail-call/loop-across-modules.wast", + "multi-memory/simple.wast", "threads/LB.wast", "threads/LB_atomic.wast", + "threads/MP.wast", "threads/MP_atomic.wast", "threads/MP_wait.wast", + "threads/SB.wast", "threads/SB_atomic.wast", "threads/atomics-end-of-memory.wast", + "threads/atomics_notify.wast", "threads/atomics_wait_address.wast", + "threads/load-store-alignment.wast", "threads/wait_notify.wast", + "memory64/bounds.wast", "memory64/codegen.wast", "memory64/linking-errors.wast", + "memory64/linking.wast", "memory64/multi-memory.wast", "memory64/offsets.wast", + "memory64/simd.wast", "memory64/threads.wast", "gc/alloc-v128-struct.wast", + "gc/anyref_that_is_i31_barriers.wast", "gc/array-alloc-too-large.wast", + "gc/array-init-data.wast", "gc/array-new-data.wast", "gc/array-new-elem.wast", + "gc/array-types.wast", "gc/arrays-of-different-types.wast", + "gc/externrefs-can-be-i31refs.wast", "gc/func-refs-in-gc-heap.wast", + "gc/fuzz-segfault.wast", "gc/i31ref-of-global-initializers.wast", + "gc/i31ref-tables.wast", "gc/issue-10171.wast", "gc/issue-10182.wast", + "gc/issue-10353.wast", "gc/issue-10397.wast", "gc/issue-10459.wast", + "gc/issue-10467.wast", "gc/more-rec-groups-than-types.wast", "gc/null-i31ref.wast", + "gc/rec-group-funcs.wast", "gc/ref-test.wast", "gc/struct-instructions.wast", + "gc/struct-types.wast", +] + + +def parse_skip_file() -> tuple[list[str], list[str]]: + skip_dirs: list[str] = [] + skip_files: list[str] = [] + for raw in SKIP_FILE.read_text(encoding="utf-8").splitlines(): + line = raw.split("#", 1)[0].rstrip() + if not line: + continue + if line.endswith("/"): + skip_dirs.append(line) + else: + skip_files.append(line) + return skip_dirs, skip_files + + +def is_skipped(src: Path, root: Path, skip_dirs: list[str], skip_files: list[str]) -> bool: + rel = src.relative_to(root).as_posix() + if any(rel.startswith(prefix) for prefix in skip_dirs): + return True + return src.name in skip_files + + +def flatten_name(src: Path, root: Path) -> tuple[str, str]: + rel = src.relative_to(root).as_posix() + name = src.name + base = src.stem + if "/" not in rel: + return name, base + prefix = rel.split("/", 1)[0].replace("-", "_") + if prefix == "function_references": + prefix = "funcref" + return f"{prefix}_{name}", f"{prefix}_{base}" + + +def selected_files(batch: str | None, root: Path) -> list[Path]: + mapping = { + "1": BATCH1, + "2": BATCH2, + "3": BATCH3 + BATCH3_WAT, + "4": BATCH4_SIMD, + "5": BATCH5_PROPOSALS, + } + rels = mapping.get(batch, BATCH1 + BATCH2 + BATCH3 + BATCH3_WAT + BATCH4_SIMD + BATCH5_PROPOSALS) + return [root / rel for rel in rels] + + +def convert_with_wasm_tools(src: Path, out_json: Path) -> bool: + result = subprocess.run( + ["wasm-tools", "json-from-wast", str(src), "-o", str(out_json), "--wasm-dir", str(JSON_DIR)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="Copy and convert wasmtime misc_testsuite files.") + parser.add_argument("--batch") + args = parser.parse_args() + + wasmtime_misc = Path(os.environ.get("WASMTIME_MISC_DIR", str(Path.home() / "Documents" / "OSS" / "wasmtime" / "tests" / "misc_testsuite"))) + if not wasmtime_misc.is_dir(): + print(f"ERROR: wasmtime misc_testsuite not found at {wasmtime_misc}") + return 1 + + if shutil.which("wasm-tools") is None: + print("ERROR: wasm-tools not found") + return 1 + + WAST_DIR.mkdir(parents=True, exist_ok=True) + JSON_DIR.mkdir(parents=True, exist_ok=True) + + skip_dirs, skip_files = parse_skip_file() + converted = skipped = failed = copied = 0 + + for src in selected_files(args.batch, wasmtime_misc): + if not src.is_file(): + continue + if is_skipped(src, wasmtime_misc, skip_dirs, skip_files): + skipped += 1 + continue + + flat_name, flat_base = flatten_name(src, wasmtime_misc) + dest = WAST_DIR / flat_name + shutil.copy2(src, dest) + copied += 1 + + if src.suffix == ".wast": + if convert_with_wasm_tools(dest, JSON_DIR / f"{flat_base}.json"): + converted += 1 + else: + print(f"WARN: failed to convert {flat_name}") + failed += 1 + elif src.suffix == ".wat": + print(f"NOTE: {flat_name} is .wat; skipping JSON conversion") + skipped += 1 + + print() + print(f"Copied: {copied}, Converted: {converted}, Skipped: {skipped}, Failed: {failed}") + print(f"WAST dir: {WAST_DIR}") + print(f"JSON dir: {JSON_DIR}") + + print() + print("--- Custom proposal generators ---") + generators = [] + if (WAST_DIR / "wide-arithmetic.wast").is_file(): + generators.append(ROOT / "test" / "e2e" / "gen_wide_arithmetic.py") + generators.append(ROOT / "test" / "e2e" / "gen_custom_page_sizes.py") + for generator in generators: + result = subprocess.run([sys.executable, str(generator)], check=False) + name = generator.stem.replace("gen_", "").replace("_", "-") + print(f"{'OK' if result.returncode == 0 else 'FAIL'}: {name}") + if result.returncode != 0: + failed += 1 + + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/e2e/convert.sh b/test/e2e/convert.sh index 7a2839e8..f5e128cb 100755 --- a/test/e2e/convert.sh +++ b/test/e2e/convert.sh @@ -1,250 +1,4 @@ #!/bin/bash -# Copy + convert wasmtime misc_testsuite .wast files for zwasm e2e testing. -# -# Usage: bash test/e2e/convert.sh [--batch N] -# --batch 1 Priority 1: Core MVP & Traps (~25 files) -# --batch 2 Priority 2: Float & Reference Types (~15 files) -# --batch 3 Priority 3: Programs & Regressions (~20 files) -# --batch 4 Priority 4: SIMD (~15 files) -# --batch 5 Priority 5: Proposals (GC, Threads, Memory64, etc.) -# (no flag) All portable files - -set -e +set -euo pipefail cd "$(dirname "$0")/../.." - -WASMTIME_MISC="${WASMTIME_MISC_DIR:-$HOME/Documents/OSS/wasmtime/tests/misc_testsuite}" -WAST_DIR="test/e2e/wast" -JSON_DIR="test/e2e/json" -SKIP_FILE="test/e2e/skip.txt" - -if [ ! -d "$WASMTIME_MISC" ]; then - echo "ERROR: wasmtime misc_testsuite not found at $WASMTIME_MISC" - exit 1 -fi - -mkdir -p "$WAST_DIR" "$JSON_DIR" - -# Build skip patterns from skip.txt -SKIP_DIRS=() -SKIP_FILES=() -while IFS= read -r line; do - line="${line%%#*}" # strip comments - line="${line%"${line##*[! ]}"}" # strip trailing spaces - [ -z "$line" ] && continue - if [[ "$line" == */ ]]; then - SKIP_DIRS+=("$line") - else - SKIP_FILES+=("$line") - fi -done < "$SKIP_FILE" - -is_skipped() { - local file="$1" - local name - name=$(basename "$file") - local relpath="${file#$WASMTIME_MISC/}" - - # Check directory skips - for dir in "${SKIP_DIRS[@]}"; do - if [[ "$relpath" == "$dir"* ]]; then - return 0 - fi - done - - # Check file skips - for skip in "${SKIP_FILES[@]}"; do - if [[ "$name" == "$skip" ]]; then - return 0 - fi - done - - return 1 -} - -# Batch file lists -BATCH1=( - add.wast div-rem.wast mul16-negative.wast wide-arithmetic.wast - control-flow.wast br-table-fuzzbug.wast simple-unreachable.wast - misc_traps.wast stack_overflow.wast no-panic.wast no-panic-on-invalid.wast - memory-copy.wast imported-memory-copy.wast - partial-init-memory-segment.wast - call_indirect.wast many-results.wast many-return-values.wast - export-large-signature.wast func-400-params.wast - table_copy.wast table_copy_on_imported_tables.wast - elem_drop.wast elem-ref-null.wast table_grow_with_funcref.wast - linking-errors.wast empty.wast -) - -BATCH2=( - f64-copysign.wast float-round-doesnt-load-too-much.wast - int-to-float-splat.wast sink-float-but-dont-trap.wast - externref-id-function.wast externref-segment.wast - mutable_externref_globals.wast simple_ref_is_null.wast - externref-table-dropped-segment-issue-8281.wast - bit-and-conditions.wast no-opt-panic-dividing-by-zero.wast - partial-init-table-segment.wast - many_table_gets_lead_to_gc.wast - no-mixup-stack-maps.wast rs2wasm-add-func.wast -) - -BATCH3=( - embenchen_fannkuch.wast embenchen_fasta.wast - embenchen_ifs.wast embenchen_primes.wast - rust_fannkuch.wast fib.wast - issue1809.wast issue4840.wast issue4857.wast issue4890.wast - issue6562.wast issue694.wast - issue11748.wast issue12318.wast -) - -# .wat files need special handling (none currently — issue11563.wat needs GC, issue12170.wat has no assertions) -BATCH3_WAT=() - -BATCH4_SIMD=( - simd/cvt-from-uint.wast - simd/edge-of-memory.wast simd/unaligned-load.wast - simd/load_splat_out_of_bounds.wast simd/v128-select.wast - simd/replace-lane-preserve.wast simd/almost-extmul.wast - simd/interesting-float-splat.wast - simd/issue4807.wast simd/issue6725-no-egraph-panic.wast - simd/issue_3173_select_v128.wast simd/issue_3327_bnot_lowering.wast - simd/spillslot-size-fuzzbug.wast simd/sse-cannot-fold-unaligned-loads.wast -) - -# Batch 5: Proposal-specific E2E tests -BATCH5_PROPOSALS=( - # Function references (instance.wast excluded: uses module_definition/module_instance) - function-references/call_indirect.wast - function-references/table_fill.wast - function-references/table_get.wast - function-references/table_grow.wast - function-references/table_set.wast - # Tail call - tail-call/loop-across-modules.wast - # Multi-memory - multi-memory/simple.wast - # Threads - threads/LB.wast threads/LB_atomic.wast - threads/MP.wast threads/MP_atomic.wast threads/MP_wait.wast - threads/SB.wast threads/SB_atomic.wast - threads/atomics-end-of-memory.wast threads/atomics_notify.wast - threads/atomics_wait_address.wast threads/load-store-alignment.wast - threads/wait_notify.wast - # Memory64 (more-than-4gb excluded: needs >4GB RAM) - memory64/bounds.wast memory64/codegen.wast - memory64/linking-errors.wast memory64/linking.wast - memory64/multi-memory.wast memory64/offsets.wast - memory64/simd.wast memory64/threads.wast - # GC - gc/alloc-v128-struct.wast gc/anyref_that_is_i31_barriers.wast - gc/array-alloc-too-large.wast gc/array-init-data.wast - gc/array-new-data.wast gc/array-new-elem.wast - gc/array-types.wast gc/arrays-of-different-types.wast - gc/externrefs-can-be-i31refs.wast gc/func-refs-in-gc-heap.wast - gc/fuzz-segfault.wast gc/i31ref-of-global-initializers.wast - gc/i31ref-tables.wast gc/issue-10171.wast gc/issue-10182.wast - gc/issue-10353.wast gc/issue-10397.wast gc/issue-10459.wast - gc/issue-10467.wast gc/more-rec-groups-than-types.wast - gc/null-i31ref.wast gc/rec-group-funcs.wast gc/ref-test.wast - gc/struct-instructions.wast gc/struct-types.wast -) - -# Determine which files to process -BATCH="${1#--batch=}" -[ "$1" = "--batch" ] && BATCH="$2" - -collect_files() { - local files=() - case "$BATCH" in - 1) - for f in "${BATCH1[@]}"; do files+=("$WASMTIME_MISC/$f"); done - ;; - 2) - for f in "${BATCH2[@]}"; do files+=("$WASMTIME_MISC/$f"); done - ;; - 3) - for f in "${BATCH3[@]}"; do files+=("$WASMTIME_MISC/$f"); done - for f in "${BATCH3_WAT[@]}"; do files+=("$WASMTIME_MISC/$f"); done - ;; - 4) - for f in "${BATCH4_SIMD[@]}"; do files+=("$WASMTIME_MISC/$f"); done - ;; - 5) - for f in "${BATCH5_PROPOSALS[@]}"; do files+=("$WASMTIME_MISC/$f"); done - ;; - *) - # All batches combined (explicit list — no wildcard scan) - for f in "${BATCH1[@]}"; do files+=("$WASMTIME_MISC/$f"); done - for f in "${BATCH2[@]}"; do files+=("$WASMTIME_MISC/$f"); done - for f in "${BATCH3[@]}"; do files+=("$WASMTIME_MISC/$f"); done - for f in "${BATCH3_WAT[@]}"; do files+=("$WASMTIME_MISC/$f"); done - for f in "${BATCH4_SIMD[@]}"; do files+=("$WASMTIME_MISC/$f"); done - for f in "${BATCH5_PROPOSALS[@]}"; do files+=("$WASMTIME_MISC/$f"); done - ;; - esac - echo "${files[@]}" -} - -FILES=($(collect_files)) -CONVERTED=0 -SKIPPED=0 -FAILED=0 -COPIED=0 - -for src in "${FILES[@]}"; do - [ -f "$src" ] || continue - - if is_skipped "$src"; then - SKIPPED=$((SKIPPED + 1)) - continue - fi - - name=$(basename "$src") - base="${name%.*}" - ext="${name##*.}" - - # For subdir files, flatten name with dir_ prefix (e.g. simd/foo.wast → simd_foo.wast) - relpath="${src#$WASMTIME_MISC/}" - if [[ "$relpath" == */* ]]; then - prefix="${relpath%%/*}" - # Normalize: function-references → funcref, custom-page-sizes → custom_page_sizes - prefix="${prefix//-/_}" - [ "$prefix" = "function_references" ] && prefix="funcref" - flat_name="${prefix}_${name}" - flat_base="${prefix}_${base}" - else - flat_name="$name" - flat_base="$base" - fi - - # Copy to wast dir - cp "$src" "$WAST_DIR/$flat_name" - COPIED=$((COPIED + 1)) - - # Convert to JSON - if [[ "$ext" == "wast" ]]; then - if wasm-tools json-from-wast "$WAST_DIR/$flat_name" -o "$JSON_DIR/$flat_base.json" --wasm-dir "$JSON_DIR/" 2>/dev/null; then - CONVERTED=$((CONVERTED + 1)) - else - echo "WARN: failed to convert $flat_name" - FAILED=$((FAILED + 1)) - fi - elif [[ "$ext" == "wat" ]]; then - # .wat files: just validate/copy the wasm — run_spec.py can't use them directly - # We'll handle .wat files separately if needed - echo "NOTE: $flat_name is .wat (not .wast) — skipping JSON conversion" - SKIPPED=$((SKIPPED + 1)) - fi -done - -echo "" -echo "Copied: $COPIED, Converted: $CONVERTED, Skipped: $SKIPPED, Failed: $FAILED" -echo "WAST dir: $WAST_DIR/" -echo "JSON dir: $JSON_DIR/" - -# Custom generators for proposals that wast2json cannot handle -echo "" -echo "--- Custom proposal generators ---" -if [ -f "$WAST_DIR/wide-arithmetic.wast" ]; then - python3 test/e2e/gen_wide_arithmetic.py && echo "OK: wide-arithmetic" || echo "FAIL: wide-arithmetic" -fi -python3 test/e2e/gen_custom_page_sizes.py && echo "OK: custom-page-sizes" || echo "FAIL: custom-page-sizes" +exec python3 test/e2e/convert.py "$@" diff --git a/test/e2e/run_e2e.py b/test/e2e/run_e2e.py new file mode 100644 index 00000000..04c2d543 --- /dev/null +++ b/test/e2e/run_e2e.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +JSON_DIR = ROOT / "test" / "e2e" / "json" + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run zwasm E2E tests.") + parser.add_argument("--summary", action="store_true") + parser.add_argument("--verbose", "-v", action="store_true") + parser.add_argument("--convert", action="store_true") + parser.add_argument("--batch") + args = parser.parse_args() + + if args.convert or not JSON_DIR.exists() or not any(JSON_DIR.iterdir()): + print("Converting e2e test files...") + cmd = [sys.executable, str(ROOT / "test" / "e2e" / "convert.py")] + if args.batch: + cmd.extend(["--batch", args.batch]) + result = subprocess.run(cmd, check=False) + if result.returncode != 0: + return result.returncode + print() + + if not JSON_DIR.exists() or not any(JSON_DIR.iterdir()): + print(f"ERROR: No JSON test files in {JSON_DIR}") + print(f"Run: {sys.executable} test/e2e/convert.py") + return 1 + + exe_name = "e2e_runner.exe" if sys.platform == "win32" else "e2e_runner" + runner = ROOT / "zig-out" / "bin" / exe_name + if not runner.is_file(): + print("Building e2e_runner...") + result = subprocess.run(["zig", "build", "e2e"], cwd=ROOT, check=False) + if result.returncode != 0: + return result.returncode + + cmd = [str(runner), "--dir", str(JSON_DIR)] + if args.summary: + cmd.append("--summary") + if args.verbose: + cmd.append("-v") + print("Running e2e tests...") + return subprocess.run(cmd, cwd=ROOT, check=False).returncode + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/e2e/run_e2e.sh b/test/e2e/run_e2e.sh index 91bbacbe..6baf395e 100755 --- a/test/e2e/run_e2e.sh +++ b/test/e2e/run_e2e.sh @@ -1,61 +1,4 @@ #!/bin/bash -# Run zwasm e2e tests (wasmtime misc_testsuite port). -# -# Usage: -# bash test/e2e/run_e2e.sh [--summary] [--verbose] [--convert] [--batch N] -# -# Options: -# --summary Show per-file summary -# --verbose Show individual failures -# --convert Re-run conversion before testing -# --batch N Only convert/run batch N (1-4) - -set -e +set -euo pipefail cd "$(dirname "$0")/../.." - -SUMMARY="" -VERBOSE="" -CONVERT="" -BATCH="" - -while [[ $# -gt 0 ]]; do - case "$1" in - --summary) SUMMARY="--summary"; shift ;; - --verbose|-v) VERBOSE="-v"; shift ;; - --convert) CONVERT=1; shift ;; - --batch) BATCH="$2"; shift 2 ;; - --batch=*) BATCH="${1#--batch=}"; shift ;; - *) echo "Unknown option: $1"; exit 1 ;; - esac -done - -JSON_DIR="test/e2e/json" - -# Convert if requested or json dir is empty -if [ -n "$CONVERT" ] || [ ! "$(ls -A "$JSON_DIR" 2>/dev/null)" ]; then - echo "Converting e2e test files..." - if [ -n "$BATCH" ]; then - bash test/e2e/convert.sh --batch "$BATCH" - else - bash test/e2e/convert.sh - fi - echo "" -fi - -# Check that we have test files -if [ ! "$(ls -A "$JSON_DIR" 2>/dev/null)" ]; then - echo "ERROR: No JSON test files in $JSON_DIR" - echo "Run: bash test/e2e/convert.sh" - exit 1 -fi - -# Build e2e_runner if needed -RUNNER="./zig-out/bin/e2e_runner" -if [ ! -f "$RUNNER" ]; then - echo "Building e2e_runner..." - zig build e2e -fi - -# Run tests using Zig E2E runner -echo "Running e2e tests..." -$RUNNER --dir "$JSON_DIR" $SUMMARY $VERBOSE +exec python3 test/e2e/run_e2e.py "$@" diff --git a/test/realworld/build_all.py b/test/realworld/build_all.py new file mode 100644 index 00000000..bb9527a5 --- /dev/null +++ b/test/realworld/build_all.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT_DIR = ROOT / "test" / "realworld" +WASM_DIR = SCRIPT_DIR / "wasm" + + +class BuildState: + def __init__(self) -> None: + self.pass_count = 0 + self.fail_count = 0 + self.skip_count = 0 + self.errors: list[str] = [] + + def log_pass(self, name: str) -> None: + print(f" PASS: {name}") + self.pass_count += 1 + + def log_fail(self, name: str, error: str) -> None: + print(f" FAIL: {name} - {error}") + self.fail_count += 1 + self.errors.append(f" {name}: {error}") + + def log_skip(self, name: str, reason: str) -> None: + print(f" SKIP: {name} - {reason}") + self.skip_count += 1 + + +def up_to_date(src: Path, out: Path, force: bool) -> bool: + if force or not out.is_file(): + return False + return src.stat().st_mtime <= out.stat().st_mtime + + +def run_command(cmd: list[str], cwd: Path | None = None, env: dict[str, str] | None = None) -> tuple[bool, str]: + result = subprocess.run(cmd, cwd=cwd, env=env, capture_output=True, text=True, check=False) + if result.returncode == 0: + return True, "" + return False, (result.stderr or result.stdout).strip() or f"exit {result.returncode}" + + +def build_c(state: BuildState, force: bool) -> None: + print("=== Building C programs (wasi-sdk) ===") + wasi_sdk = Path(os.environ.get("WASI_SDK_PATH", "")) + cc = wasi_sdk / "bin" / ("clang.exe" if sys.platform == "win32" else "clang") + sysroot = wasi_sdk / "share" / "wasi-sysroot" + sources = sorted((SCRIPT_DIR / "c").glob("*.c")) + if not cc.is_file(): + state.log_skip("c_*", "WASI_SDK_PATH not set or clang not found") + return + for src in sources: + name = src.stem + out = WASM_DIR / f"c_{name}.wasm" + if up_to_date(src, out, force): + state.log_skip(f"c_{name}", "up to date") + continue + ok, err = run_command([str(cc), f"--sysroot={sysroot}", "-O2", "-o", str(out), str(src), "-lm"]) + if ok: + state.log_pass(f"c_{name}") + else: + state.log_fail(f"c_{name}", err) + + +def build_cpp(state: BuildState, force: bool) -> None: + print("\n=== Building C++ programs (wasi-sdk) ===") + wasi_sdk = Path(os.environ.get("WASI_SDK_PATH", "")) + cxx = wasi_sdk / "bin" / ("clang++.exe" if sys.platform == "win32" else "clang++") + sysroot = wasi_sdk / "share" / "wasi-sysroot" + sources = sorted((SCRIPT_DIR / "cpp").glob("*.cpp")) + if not cxx.is_file(): + state.log_skip("cpp_*", "WASI_SDK_PATH not set or clang++ not found") + return + for src in sources: + name = src.stem + out = WASM_DIR / f"cpp_{name}.wasm" + if up_to_date(src, out, force): + state.log_skip(f"cpp_{name}", "up to date") + continue + ok, err = run_command([str(cxx), f"--sysroot={sysroot}", "-O2", "-fno-exceptions", "-o", str(out), str(src)]) + if ok: + state.log_pass(f"cpp_{name}") + else: + state.log_fail(f"cpp_{name}", err) + + +def build_go(state: BuildState, force: bool) -> None: + print("\n=== Building Go programs (wasip1/wasm) ===") + go = shutil.which("go") + if go is None: + state.log_skip("go_*", "go not found") + return + for directory in sorted((SCRIPT_DIR / "go").glob("*/")): + name = directory.name + out = WASM_DIR / f"go_{name}.wasm" + src = directory / "main.go" + if up_to_date(src, out, force): + state.log_skip(f"go_{name}", "up to date") + continue + env = os.environ.copy() + env["GOOS"] = "wasip1" + env["GOARCH"] = "wasm" + ok, err = run_command([go, "build", "-o", str(out), "."], cwd=directory, env=env) + if ok: + state.log_pass(f"go_{name}") + else: + state.log_fail(f"go_{name}", err) + + +def build_rust(state: BuildState, force: bool) -> None: + print("\n=== Building Rust programs (wasm32-wasip1) ===") + cargo = shutil.which("cargo") + rustup = shutil.which("rustup") + if cargo is None or rustup is None: + state.log_skip("rust_*", "cargo or rustup not found") + return + installed = subprocess.run([rustup, "target", "list", "--installed"], capture_output=True, text=True, check=False) + if installed.returncode != 0 or "wasm32-wasip1" not in installed.stdout.split(): + state.log_skip("rust_*", "cargo or wasm32-wasip1 target not found") + return + for directory in sorted((SCRIPT_DIR / "rust").glob("*/")): + cargo_toml = directory / "Cargo.toml" + if not cargo_toml.is_file(): + continue + name = directory.name + out = WASM_DIR / f"rust_{name}.wasm" + if up_to_date(cargo_toml, out, force): + state.log_skip(f"rust_{name}", "up to date") + continue + ok, err = run_command([cargo, "build", "--manifest-path", str(cargo_toml), "--target", "wasm32-wasip1", "--release", "--quiet"], cwd=SCRIPT_DIR) + if ok: + built = directory / "target" / "wasm32-wasip1" / "release" / f"{name}.wasm" + shutil.copy2(built, out) + state.log_pass(f"rust_{name}") + else: + state.log_fail(f"rust_{name}", err) + + +def build_tinygo(state: BuildState, force: bool) -> None: + print("\n=== Building TinyGo programs (wasip1) ===") + tinygo = shutil.which("tinygo") + if tinygo is None: + state.log_skip("tinygo_*", "tinygo not found") + return + for directory in sorted((SCRIPT_DIR / "tinygo").glob("*/")): + name = directory.name + out = WASM_DIR / f"tinygo_{name}.wasm" + src = directory / "main.go" + if up_to_date(src, out, force): + state.log_skip(f"tinygo_{name}", "up to date") + continue + ok, err = run_command([tinygo, "build", "-o", str(out), "-target=wasip1", "-scheduler=none", "."], cwd=directory) + if ok: + state.log_pass(f"tinygo_{name}") + else: + state.log_fail(f"tinygo_{name}", err) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Build all real-world wasm test programs.") + parser.add_argument("--force", action="store_true") + args = parser.parse_args() + + WASM_DIR.mkdir(parents=True, exist_ok=True) + state = BuildState() + + build_c(state, args.force) + build_cpp(state, args.force) + build_go(state, args.force) + build_rust(state, args.force) + build_tinygo(state, args.force) + + print("\n=== Summary ===") + print(f"PASS: {state.pass_count} FAIL: {state.fail_count} SKIP: {state.skip_count}") + if state.errors: + print("Errors:") + for error in state.errors: + print(error) + print(f"\nWasm files in {WASM_DIR}:") + files = sorted(WASM_DIR.glob("*.wasm")) + if not files: + print(" (none)") + else: + for wasm in files: + print(f" {wasm.name}") + return state.fail_count + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/realworld/build_all.sh b/test/realworld/build_all.sh index 84b46ed3..a90d705c 100755 --- a/test/realworld/build_all.sh +++ b/test/realworld/build_all.sh @@ -1,161 +1,4 @@ -#!/usr/bin/env bash -# build_all.sh — Build all real-world wasm test programs -# -# Usage: bash test/realworld/build_all.sh [--force] -# -# Requires: cargo (+ wasm32-wasip1 target), go, wasi-sdk ($WASI_SDK_PATH) - +#!/bin/bash set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -WASM_DIR="$SCRIPT_DIR/wasm" -mkdir -p "$WASM_DIR" - -FORCE=0 -for arg in "$@"; do - case "$arg" in - --force) FORCE=1 ;; - esac -done - -PASS=0 -FAIL=0 -SKIP=0 -ERRORS="" - -log_pass() { echo " PASS: $1"; PASS=$((PASS + 1)); } -log_fail() { echo " FAIL: $1 — $2"; FAIL=$((FAIL + 1)); ERRORS="$ERRORS\n $1: $2"; } -log_skip() { echo " SKIP: $1 — $2"; SKIP=$((SKIP + 1)); } - -up_to_date() { - local src="$1" out="$2" - [ "$FORCE" = "1" ] && return 1 - [ ! -f "$out" ] && return 1 - [ "$src" -nt "$out" ] && return 1 - return 0 -} - -echo "=== Building C programs (wasi-sdk) ===" -if [ -n "${WASI_SDK_PATH:-}" ] && [ -f "$WASI_SDK_PATH/bin/clang" ]; then - CC="$WASI_SDK_PATH/bin/clang" - SYSROOT="$WASI_SDK_PATH/share/wasi-sysroot" - for src in "$SCRIPT_DIR"/c/*.c; do - name=$(basename "$src" .c) - out="$WASM_DIR/c_${name}.wasm" - if up_to_date "$src" "$out"; then - log_skip "c_${name}" "up to date" - continue - fi - if "$CC" --sysroot="$SYSROOT" -O2 -o "$out" "$src" -lm 2>/tmp/build_err_$$; then - log_pass "c_${name}" - else - log_fail "c_${name}" "$(cat /tmp/build_err_$$)" - fi - rm -f /tmp/build_err_$$ - done -else - log_skip "c_*" "WASI_SDK_PATH not set or clang not found" -fi - -echo "" -echo "=== Building C++ programs (wasi-sdk) ===" -if [ -n "${WASI_SDK_PATH:-}" ] && [ -f "$WASI_SDK_PATH/bin/clang++" ]; then - CXX="$WASI_SDK_PATH/bin/clang++" - SYSROOT="$WASI_SDK_PATH/share/wasi-sysroot" - for src in "$SCRIPT_DIR"/cpp/*.cpp; do - name=$(basename "$src" .cpp) - out="$WASM_DIR/cpp_${name}.wasm" - if up_to_date "$src" "$out"; then - log_skip "cpp_${name}" "up to date" - continue - fi - if "$CXX" --sysroot="$SYSROOT" -O2 -fno-exceptions -o "$out" "$src" 2>/tmp/build_err_$$; then - log_pass "cpp_${name}" - else - log_fail "cpp_${name}" "$(cat /tmp/build_err_$$)" - fi - rm -f /tmp/build_err_$$ - done -else - log_skip "cpp_*" "WASI_SDK_PATH not set or clang++ not found" -fi - -echo "" -echo "=== Building Go programs (wasip1/wasm) ===" -if command -v go &>/dev/null; then - for dir in "$SCRIPT_DIR"/go/*/; do - name=$(basename "$dir") - out="$WASM_DIR/go_${name}.wasm" - src="$dir/main.go" - if up_to_date "$src" "$out"; then - log_skip "go_${name}" "up to date" - continue - fi - if (cd "$dir" && GOOS=wasip1 GOARCH=wasm go build -o "$out" .) 2>/tmp/build_err_$$; then - log_pass "go_${name}" - else - log_fail "go_${name}" "$(cat /tmp/build_err_$$)" - fi - rm -f /tmp/build_err_$$ - done -else - log_skip "go_*" "go not found" -fi - -echo "" -echo "=== Building Rust programs (wasm32-wasip1) ===" -if command -v cargo &>/dev/null && rustup target list --installed 2>/dev/null | grep -q wasm32-wasip1; then - for dir in "$SCRIPT_DIR"/rust/*/; do - name=$(basename "$dir") - out="$WASM_DIR/rust_${name}.wasm" - cargo_toml="$dir/Cargo.toml" - if up_to_date "$cargo_toml" "$out"; then - log_skip "rust_${name}" "up to date" - continue - fi - if cargo build --manifest-path "$cargo_toml" --target wasm32-wasip1 --release --quiet 2>/tmp/build_err_$$; then - # Copy from target directory - cp "$dir/target/wasm32-wasip1/release/${name}.wasm" "$out" - log_pass "rust_${name}" - else - log_fail "rust_${name}" "$(cat /tmp/build_err_$$)" - fi - rm -f /tmp/build_err_$$ - done -else - log_skip "rust_*" "cargo or wasm32-wasip1 target not found" -fi - -echo "" -echo "=== Building TinyGo programs (wasip1) ===" -if command -v tinygo &>/dev/null; then - for dir in "$SCRIPT_DIR"/tinygo/*/; do - name=$(basename "$dir") - out="$WASM_DIR/tinygo_${name}.wasm" - src="$dir/main.go" - if up_to_date "$src" "$out"; then - log_skip "tinygo_${name}" "up to date" - continue - fi - if (cd "$dir" && tinygo build -o "$out" -target=wasip1 -scheduler=none .) 2>/tmp/build_err_$$; then - log_pass "tinygo_${name}" - else - log_fail "tinygo_${name}" "$(cat /tmp/build_err_$$)" - fi - rm -f /tmp/build_err_$$ - done -else - log_skip "tinygo_*" "tinygo not found" -fi - -echo "" -echo "=== Summary ===" -echo "PASS: $PASS FAIL: $FAIL SKIP: $SKIP" -if [ -n "$ERRORS" ]; then - echo -e "Errors:$ERRORS" -fi -echo "" -echo "Wasm files in $WASM_DIR:" -ls -lh "$WASM_DIR"/*.wasm 2>/dev/null || echo " (none)" - -exit $FAIL +cd "$(dirname "$0")/../.." +exec python3 test/realworld/build_all.py "$@" diff --git a/test/realworld/run_compat.py b/test/realworld/run_compat.py new file mode 100644 index 00000000..9780e261 --- /dev/null +++ b/test/realworld/run_compat.py @@ -0,0 +1,123 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +from pathlib import Path +import re +import shutil +import subprocess +import sys +import tempfile + + +ROOT = Path(__file__).resolve().parents[2] +SCRIPT_DIR = ROOT / "test" / "realworld" +WASM_DIR = SCRIPT_DIR / "wasm" + + +def normalize_output(text: str) -> str: + return re.sub(r"argv\[0\] = .*[\\/]", "argv[0] = ", text) + + +def run_process(cmd: list[str], cwd: Path | None = None) -> tuple[int, str, str]: + result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True, check=False) + return result.returncode, result.stdout, result.stderr + + +def main() -> int: + parser = argparse.ArgumentParser(description="Compare zwasm vs wasmtime on real-world wasm programs.") + parser.add_argument("--verbose", "-v", action="store_true") + args = parser.parse_args() + + exe_name = "zwasm.exe" if sys.platform == "win32" else "zwasm" + zwasm = ROOT / "zig-out" / "bin" / exe_name + if not zwasm.is_file(): + print("Building zwasm...") + result = subprocess.run(["zig", "build", "-Doptimize=ReleaseSafe"], cwd=ROOT, check=False) + if result.returncode != 0: + return result.returncode + + wasmtime = shutil.which("wasmtime") + if wasmtime is None: + print("wasmtime not found in PATH") + return 1 + + wasm_files = sorted(WASM_DIR.glob("*.wasm")) + if not wasm_files: + print("No wasm files found. Run build_all.py first.") + return 1 + + pass_count = fail_count = crash_count = total = 0 + results: list[str] = [] + + with tempfile.TemporaryDirectory(prefix="zwasm-realworld-") as tmp: + tmp_dir = Path(tmp) + print("=== Compatibility Test: zwasm vs wasmtime ===") + _, zwasm_version, _ = run_process([str(zwasm), "--version"]) + _, wasmtime_version, _ = run_process([wasmtime, "--version"]) + print(f"zwasm: {zwasm_version.strip() or 'unknown'}") + print(f"wasmtime: {wasmtime_version.strip() or 'unknown'}") + print() + + for wasm in wasm_files: + total += 1 + name = wasm.stem + + extra_args: list[str] = [] + wt_extra: list[str] = [] + zw_extra: list[str] = ["--allow-all"] + if "hello_wasi" in name or name == "tinygo_hello": + extra_args = ["arg1", "arg2"] + if "file_io" in name: + guest_file = tmp_dir / "zwasm_test_file_io.txt" + guest_dir = "/sandbox" + wt_extra = ["--dir", f"{tmp_dir}::{guest_dir}"] + zw_extra += ["--dir", f"{tmp_dir}::{guest_dir}"] + extra_args = [f"{guest_dir}/{guest_file.name}"] + + wt_exit, wt_out, wt_err = run_process([wasmtime, "run", *wt_extra, str(wasm), *extra_args]) + zw_exit, zw_out, zw_err = run_process([str(zwasm), "run", *zw_extra, str(wasm), *extra_args]) + + wt_norm = normalize_output(wt_out) + zw_norm = normalize_output(zw_out) + + if zw_exit > 128: + status = "CRASH" + crash_count += 1 + results.append(f" CRASH: {name} (signal {zw_exit - 128})") + elif wt_norm == zw_norm and wt_exit == zw_exit: + status = "PASS" + pass_count += 1 + elif wt_norm == zw_norm: + status = "EXIT_DIFF" + fail_count += 1 + results.append(f" EXIT_DIFF: {name} (wasmtime={wt_exit}, zwasm={zw_exit})") + else: + status = "DIFF" + fail_count += 1 + results.append(f" DIFF: {name}") + if args.verbose: + print(" wasmtime stdout (normalized):") + print("\n".join(wt_norm.splitlines()[:20])) + print(" zwasm stdout (normalized):") + print("\n".join(zw_norm.splitlines()[:20])) + print(" wasmtime stderr:") + print("\n".join(wt_err.splitlines()[:20])) + print(" zwasm stderr:") + print("\n".join(zw_err.splitlines()[:20])) + + print(f" {status:<9}{name}") + + print() + print("=== Summary ===") + print(f"PASS: {pass_count} FAIL: {fail_count} CRASH: {crash_count} TOTAL: {total}") + if results and fail_count + crash_count > 0: + print("Details:") + for line in results: + print(line) + return fail_count + crash_count + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/realworld/run_compat.sh b/test/realworld/run_compat.sh index 5206f339..76253516 100755 --- a/test/realworld/run_compat.sh +++ b/test/realworld/run_compat.sh @@ -1,125 +1,4 @@ -#!/usr/bin/env bash -# run_compat.sh — Compare zwasm vs wasmtime on real-world wasm programs -# -# Usage: bash test/realworld/run_compat.sh [--verbose] -# -# Requires: zwasm (zig-out/bin/zwasm) and wasmtime in PATH - +#!/bin/bash set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" -WASM_DIR="$SCRIPT_DIR/wasm" - -VERBOSE=0 -for arg in "$@"; do - case "$arg" in - --verbose|-v) VERBOSE=1 ;; - esac -done - -# Build zwasm if needed -if [ ! -f "$PROJECT_ROOT/zig-out/bin/zwasm" ]; then - echo "Building zwasm..." - (cd "$PROJECT_ROOT" && zig build -Doptimize=ReleaseSafe) -fi - -ZWASM="$PROJECT_ROOT/zig-out/bin/zwasm" - -PASS=0 -FAIL=0 -CRASH=0 -TOTAL=0 -RESULTS="" - -TMP_DIR=$(mktemp -d) -trap "rm -rf $TMP_DIR" EXIT - -run_test() { - local wasm="$1" - local name=$(basename "$wasm" .wasm) - TOTAL=$((TOTAL + 1)) - - local wasmtime_out="$TMP_DIR/${name}_wasmtime_out" - local wasmtime_err="$TMP_DIR/${name}_wasmtime_err" - local zwasm_out="$TMP_DIR/${name}_zwasm_out" - local zwasm_err="$TMP_DIR/${name}_zwasm_err" - - # Determine extra args - local extra_args="" - local zwasm_flags="--allow-all" - - # Per-program configuration - local wt_extra="" # wasmtime-specific flags - local zw_extra="" # zwasm-specific flags - case "$name" in - *hello_wasi*|tinygo_hello) extra_args="arg1 arg2" ;; - *file_io*) wt_extra="--dir /tmp"; zw_extra="--dir /tmp" ;; - esac - - # Run wasmtime - local wt_exit=0 - wasmtime run $wt_extra "$wasm" $extra_args > "$wasmtime_out" 2> "$wasmtime_err" || wt_exit=$? - - # Run zwasm - local zw_exit=0 - $ZWASM run $zwasm_flags $zw_extra "$wasm" $extra_args > "$zwasm_out" 2> "$zwasm_err" || zw_exit=$? - - # Normalize outputs: strip path from argv[0] (wasmtime uses basename, zwasm uses full path) - sed 's|argv\[0\] = .*/|argv[0] = |' "$wasmtime_out" > "$TMP_DIR/${name}_wt_norm" - sed 's|argv\[0\] = .*/|argv[0] = |' "$zwasm_out" > "$TMP_DIR/${name}_zw_norm" - - # Compare - local status="" - if [ $zw_exit -gt 128 ]; then - status="CRASH" - CRASH=$((CRASH + 1)) - RESULTS="$RESULTS\n CRASH: $name (signal $((zw_exit - 128)))" - elif diff -q "$TMP_DIR/${name}_wt_norm" "$TMP_DIR/${name}_zw_norm" > /dev/null 2>&1; then - if [ $wt_exit -eq $zw_exit ]; then - status="PASS" - PASS=$((PASS + 1)) - else - status="EXIT_DIFF" - FAIL=$((FAIL + 1)) - RESULTS="$RESULTS\n EXIT_DIFF: $name (wasmtime=$wt_exit, zwasm=$zw_exit)" - fi - else - status="DIFF" - FAIL=$((FAIL + 1)) - RESULTS="$RESULTS\n DIFF: $name" - if [ $VERBOSE -eq 1 ]; then - echo " wasmtime stdout (normalized):" - cat "$TMP_DIR/${name}_wt_norm" | head -20 - echo " zwasm stdout (normalized):" - cat "$TMP_DIR/${name}_zw_norm" | head -20 - echo " diff:" - diff "$TMP_DIR/${name}_wt_norm" "$TMP_DIR/${name}_zw_norm" | head -20 || true - fi - fi - - printf " %-6s %s\n" "$status" "$name" -} - -echo "=== Compatibility Test: zwasm vs wasmtime ===" -echo "zwasm: $($ZWASM --version 2>/dev/null || echo 'unknown')" -echo "wasmtime: $(wasmtime --version 2>/dev/null || echo 'unknown')" -echo "" - -if [ ! -d "$WASM_DIR" ] || [ -z "$(ls "$WASM_DIR"/*.wasm 2>/dev/null)" ]; then - echo "No wasm files found. Run build_all.sh first." - exit 1 -fi - -for wasm in "$WASM_DIR"/*.wasm; do - run_test "$wasm" -done - -echo "" -echo "=== Summary ===" -echo "PASS: $PASS FAIL: $FAIL CRASH: $CRASH TOTAL: $TOTAL" -if [ -n "$RESULTS" ] && [ $((FAIL + CRASH)) -gt 0 ]; then - echo -e "Details:$RESULTS" -fi - -exit $((FAIL + CRASH)) +cd "$(dirname "$0")/../.." +exec python3 test/realworld/run_compat.py "$@" diff --git a/test/realworld/rust/file_io/src/main.rs b/test/realworld/rust/file_io/src/main.rs index 25941a2d..c2330b31 100644 --- a/test/realworld/rust/file_io/src/main.rs +++ b/test/realworld/rust/file_io/src/main.rs @@ -2,17 +2,19 @@ use std::fs; use std::io::Write; fn main() { - let path = "/tmp/zwasm_test_file_io.txt"; + let path = std::env::args() + .nth(1) + .unwrap_or_else(|| "zwasm_test_file_io.txt".to_string()); let content = "Hello from Rust/WASI file I/O!\nLine 2\nLine 3\n"; // Write { - let mut f = fs::File::create(path).expect("failed to create file"); + let mut f = fs::File::create(&path).expect("failed to create file"); f.write_all(content.as_bytes()).expect("failed to write"); } // Read back - let read_back = fs::read_to_string(path).expect("failed to read file"); + let read_back = fs::read_to_string(&path).expect("failed to read file"); if read_back == content { println!("file_io: write/read roundtrip OK ({} bytes)", content.len()); @@ -24,6 +26,6 @@ fn main() { } // Cleanup - fs::remove_file(path).expect("failed to remove file"); + fs::remove_file(&path).expect("failed to remove file"); println!("file_io: cleanup OK"); } diff --git a/test/spec/convert.py b/test/spec/convert.py new file mode 100644 index 00000000..5adb2534 --- /dev/null +++ b/test/spec/convert.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import os +from pathlib import Path +import re +import shutil +import subprocess +import sys + + +ROOT = Path(__file__).resolve().parents[2] +OUTDIR = ROOT / "test" / "spec" / "json" +SKIP_RE = re.compile(r"memory64|address64|align64|float_memory64|binary_leb128_64") +SKIP_VARIANT_RE = re.compile(r"^(address|align|binary)[0-9]$") + + +def convert_wast(wast: Path, outname: str) -> bool: + result = subprocess.run( + [ + "wasm-tools", + "json-from-wast", + str(wast), + "-o", + str(OUTDIR / f"{outname}.json"), + "--wasm-dir", + str(OUTDIR), + ], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, + ) + return result.returncode == 0 + + +def iter_wast_files(directory: Path) -> list[Path]: + return sorted(directory.glob("*.wast")) + + +def main() -> int: + parser = argparse.ArgumentParser(description="Convert WebAssembly spec .wast files to JSON.") + parser.add_argument("testsuite", nargs="?", default="test/spec/testsuite") + args = parser.parse_args() + + testsuite = Path(args.testsuite) + if not testsuite.is_absolute(): + testsuite = ROOT / testsuite + + if not testsuite.is_dir() or not iter_wast_files(testsuite): + print(f"Testsuite not found at {testsuite}") + print("Run: git submodule update --init") + return 1 + + if shutil.which("wasm-tools") is None: + print("Error: wasm-tools not found. Install: cargo install wasm-tools") + return 1 + + OUTDIR.mkdir(parents=True, exist_ok=True) + + converted = 0 + skipped = 0 + failed = 0 + + for wast in iter_wast_files(testsuite): + name = wast.stem + if SKIP_RE.search(name) or SKIP_VARIANT_RE.search(name): + skipped += 1 + continue + if convert_wast(wast, name): + converted += 1 + else: + print(f"WARN: failed to convert {name}.wast") + failed += 1 + + for subdir in ("multi-memory", "relaxed-simd"): + sub_path = testsuite / subdir + if not sub_path.is_dir(): + continue + for wast in iter_wast_files(sub_path): + if convert_wast(wast, wast.stem): + converted += 1 + else: + print(f"WARN: failed to convert {wast.name}") + failed += 1 + + gc_root = Path(os.environ.get("GC_TESTSUITE", str(Path.home() / "Documents" / "OSS" / "WebAssembly" / "gc"))) + tsi = gc_root / "test" / "core" / "gc" / "type-subtyping-invalid.wast" + if tsi.is_file(): + if convert_wast(tsi, "type-subtyping-invalid"): + converted += 1 + else: + print("WARN: failed to convert type-subtyping-invalid.wast") + failed += 1 + + print() + print(f"Converted: {converted}, Skipped: {skipped}, Failed: {failed}") + print(f"Output: {OUTDIR}") + return 0 if failed == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/spec/convert.sh b/test/spec/convert.sh index c664640f..346a4b5d 100644 --- a/test/spec/convert.sh +++ b/test/spec/convert.sh @@ -1,94 +1,4 @@ #!/bin/bash -# Convert WebAssembly spec .wast files to JSON + .wasm using wasm-tools. -# Usage: bash test/spec/convert.sh [/path/to/testsuite] -# -# Requires: wasm-tools (https://github.com/bytecodealliance/wasm-tools) -# Defaults to the git submodule at test/spec/testsuite. -# Produces: test/spec/json/.json + test/spec/json/.N.wasm - -set -e +set -euo pipefail cd "$(dirname "$0")/../.." - -TESTSUITE="${1:-test/spec/testsuite}" - -if [ ! -d "$TESTSUITE" ] || [ -z "$(ls "$TESTSUITE"/*.wast 2>/dev/null)" ]; then - echo "Testsuite not found at $TESTSUITE" - echo "Run: git submodule update --init" - exit 1 -fi - -if ! command -v wasm-tools &>/dev/null; then - echo "Error: wasm-tools not found. Install: cargo install wasm-tools" - exit 1 -fi - -OUTDIR="test/spec/json" - -mkdir -p "$OUTDIR" - -# Skip memory64 and threads (not yet supported) -SKIP_PATTERNS="memory64|address64|align64|float_memory64|binary_leb128_64" - -CONVERTED=0 -SKIPPED=0 -FAILED=0 - -convert_wast() { - local wast="$1" - local outname="$2" - local outdir="$3" - - if wasm-tools json-from-wast "$wast" -o "$outdir/$outname.json" --wasm-dir "$outdir/" 2>/dev/null; then - CONVERTED=$((CONVERTED + 1)) - else - echo "WARN: failed to convert $outname.wast" - FAILED=$((FAILED + 1)) - fi -} - -for wast in "$TESTSUITE"/*.wast; do - name=$(basename "$wast" .wast) - - # Skip unsupported proposals - if echo "$name" | grep -qE "$SKIP_PATTERNS"; then - SKIPPED=$((SKIPPED + 1)) - continue - fi - - # Skip files with "0" or "1" suffix that are memory64/align64 variants - if echo "$name" | grep -qE '^(address|align|binary)[0-9]$'; then - SKIPPED=$((SKIPPED + 1)) - continue - fi - - convert_wast "$wast" "$name" "$OUTDIR" -done - -# Multi-memory proposal tests (in subdirectory) -MMDIR="$TESTSUITE/multi-memory" -if [ -d "$MMDIR" ]; then - for wast in "$MMDIR"/*.wast; do - name=$(basename "$wast" .wast) - convert_wast "$wast" "$name" "$OUTDIR" - done -fi - -# Relaxed SIMD proposal tests (in subdirectory) -RSDIR="$TESTSUITE/relaxed-simd" -if [ -d "$RSDIR" ]; then - for wast in "$RSDIR"/*.wast; do - name=$(basename "$wast" .wast) - convert_wast "$wast" "$name" "$OUTDIR" - done -fi - -# GC type-subtyping-invalid (only in external GC spec repo, not yet in main testsuite) -GC_TESTSUITE="${GC_TESTSUITE:-$HOME/Documents/OSS/WebAssembly/gc}" -TSI="$GC_TESTSUITE/test/core/gc/type-subtyping-invalid.wast" -if [ -f "$TSI" ]; then - convert_wast "$TSI" "type-subtyping-invalid" "$OUTDIR" -fi - -echo "" -echo "Converted: $CONVERTED, Skipped: $SKIPPED, Failed: $FAILED" -echo "Output: $OUTDIR/" +exec python3 test/spec/convert.py "$@" diff --git a/test/spec/run_spec.py b/test/spec/run_spec.py index 68ab74c4..de87de64 100644 --- a/test/spec/run_spec.py +++ b/test/spec/run_spec.py @@ -19,6 +19,9 @@ import argparse import tempfile import shutil +import queue +import threading +import time ZWASM = "./zig-out/bin/zwasm" SPEC_DIR = "test/spec/json" @@ -322,6 +325,8 @@ def __init__(self, wasm_path, linked_modules=None): self.proc = None self.needs_state = False # True if actions have been executed self._debug = False + self._stdout_queue = None + self._stdout_thread = None self._start() def _start(self): @@ -335,7 +340,27 @@ def _start(self): stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + bufsize=1, ) + self._stdout_queue = queue.Queue() + self._stdout_thread = threading.Thread(target=self._pump_stdout, daemon=True) + self._stdout_thread.start() + + def _pump_stdout(self): + """Continuously transfer stdout lines to a queue for cross-platform timeouts.""" + try: + while self.proc and self.proc.stdout: + try: + line = self.proc.stdout.readline() + except (OSError, ValueError): + break + if not line: + break + self._stdout_queue.put(line.strip()) + finally: + if self._stdout_queue is not None: + self._stdout_queue.put(None) def _has_problematic_name(self, func_name): """Check if function name contains characters that break the line protocol.""" @@ -368,16 +393,13 @@ def invoke(self, func_name, args, timeout=5): self.proc.stdin.write(cmd_line) self.proc.stdin.flush() - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: + response = self._read_response(timeout) + if response == "timeout": self.proc.kill() self._cleanup_proc() self.proc = None return (False, "timeout") - - response = self.proc.stdout.readline().strip() - if not response: + if response == "no_response": return (False, "no response") if response.startswith("ok"): parts = response.split() @@ -412,16 +434,13 @@ def invoke_on(self, mod_name, func_name, args, timeout=5): self.proc.stdin.write(cmd_line) self.proc.stdin.flush() - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: + response = self._read_response(timeout) + if response == "timeout": self.proc.kill() self._cleanup_proc() self.proc = None return (False, "timeout") - - response = self.proc.stdout.readline().strip() - if not response: + if response == "no_response": return (False, "no response") if response.startswith("ok"): parts = response.split() @@ -448,16 +467,13 @@ def get_on_global(self, mod_name, global_name, timeout=5): self.proc.stdin.write(cmd_line) self.proc.stdin.flush() - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: + response = self._read_response(timeout) + if response == "timeout": self.proc.kill() self._cleanup_proc() self.proc = None return (False, "timeout") - - response = self.proc.stdout.readline().strip() - if not response: + if response == "no_response": return (False, "no response") if response.startswith("ok"): parts = response.split() @@ -484,16 +500,13 @@ def get_global(self, global_name, timeout=5): self.proc.stdin.write(cmd_line) self.proc.stdin.flush() - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: + response = self._read_response(timeout) + if response == "timeout": self.proc.kill() self._cleanup_proc() self.proc = None return (False, "timeout") - - response = self.proc.stdout.readline().strip() - if not response: + if response == "no_response": return (False, "no response") if response.startswith("ok"): parts = response.split() @@ -509,14 +522,23 @@ def get_global(self, global_name, timeout=5): return (False, str(e)) def _cleanup_proc(self): - """Close all pipes on the process to avoid BrokenPipeError on GC.""" - if self.proc: - for pipe in (self.proc.stdin, self.proc.stdout, self.proc.stderr): - try: - if pipe: - pipe.close() - except Exception: - pass + """Clean up process-owned resources without racing the stdout pump on Windows.""" + proc = self.proc + thread = self._stdout_thread + if proc and proc.stdin: + try: + proc.stdin.close() + except Exception: + pass + if thread and thread.is_alive(): + thread.join(timeout=0.2) + if proc and proc.stderr: + try: + proc.stderr.close() + except Exception: + pass + self._stdout_thread = None + self._stdout_queue = None def send_batch_cmd(self, cmd, timeout=5): """Send a raw batch command and return (success, response).""" @@ -528,11 +550,11 @@ def send_batch_cmd(self, cmd, timeout=5): _sys.stderr.write(f" [CMD] {cmd}\n") self.proc.stdin.write(cmd + "\n") self.proc.stdin.flush() - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: + response = self._read_response(timeout) + if response == "timeout": return (False, "timeout") - response = self.proc.stdout.readline().strip() + if response == "no_response": + return (False, "no response") if self._debug: _sys.stderr.write(f" [RSP] {response}\n") return (response.startswith("ok"), response) @@ -545,11 +567,12 @@ def register_module(self, name): def _read_response(self, timeout=5): """Read a single line response from the batch process.""" - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: + if self._stdout_queue is None: + return "no_response" + try: + response = self._stdout_queue.get(timeout=timeout) + except queue.Empty: return "timeout" - response = self.proc.stdout.readline().strip() return response if response else "no_response" def load_module(self, name, wasm_path): @@ -589,15 +612,11 @@ def thread_wait(self, thread_name, timeout=30): self.proc.stdin.write(f"thread_wait {thread_name}\n") self.proc.stdin.flush() results = [] - # First read uses select for timeout; subsequent reads use direct readline - # since data may already be in Python's internal read buffer. - import select - ready, _, _ = select.select([self.proc.stdout], [], [], timeout) - if not ready: - return results + deadline = time.monotonic() + timeout while True: - line = self.proc.stdout.readline().strip() - if not line: + remaining = max(0.0, deadline - time.monotonic()) + line = self._read_response(remaining) + if line in ("timeout", "no_response"): break if line.startswith("thread_result "): results.append(line[len("thread_result "):]) @@ -610,10 +629,15 @@ def thread_wait(self, thread_name, timeout=30): def close(self): if self.proc and self.proc.poll() is None: try: - self.proc.stdin.close() + if self.proc.stdin: + self.proc.stdin.close() self.proc.wait(timeout=5) except Exception: self.proc.kill() + try: + self.proc.wait(timeout=5) + except Exception: + pass self._cleanup_proc() self.proc = None @@ -740,7 +764,7 @@ def run_test_file(json_path, verbose=False, wat_mode=False, wat_dir=None): wat_stats is a dict with keys: conv_ok, conv_fail, conv_fail_files (list). Only populated when wat_mode=True. """ - with open(json_path) as f: + with open(json_path, encoding="utf-8") as f: data = json.load(f) test_dir = os.path.dirname(json_path) From 13edebb1727f719545f0d58fa38f0975ea5d2437 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sun, 15 Mar 2026 22:07:13 +0900 Subject: [PATCH 2/5] Fix PR #8 review issues: compat regression, symlink flags, docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - run_compat.py: use --dir /tmp on Mac/Linux (guest path alias only on Windows) — fixes rust_file_io FAIL regression - wasi.zig path_filestat_get: restore fstatat + SYMLINK_NOFOLLOW on POSIX; add writeFilestatPosix preserving nlink/mode from Stat - wasi.zig fd_close: add comment explaining stdio SUCCESS (wasmtime compat) - wasi.zig writeFilestat: document nlink=1 limitation - README.md: fix duplicate Stage 33 (fuzz=33, windows=34), remove "Windows port" from Future - run_spec.py: detect .exe on Windows for ZWASM path --- README.md | 4 +-- src/wasi.zig | 58 +++++++++++++++++++++++++++++++----- test/realworld/run_compat.py | 15 ++++++---- test/spec/run_spec.py | 2 +- 4 files changed, 63 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 59c0de93..1cda336b 100644 --- a/README.md +++ b/README.md @@ -273,14 +273,14 @@ The spec test suite runs on every change. - [x] Stage 25: Lightweight self-call (fib now matches wasmtime) - [x] Stages 26-31: JIT peephole, platform verification, spec cleanup, GC benchmarks - [x] Stage 32: 100% spec conformance (62,263/62,263 on macOS + Linux) -- [x] Stage 33: Windows x86_64 native support (build, test, JIT, C API, release artifacts) - [x] Stage 33: Fuzz testing (differential testing, extended fuzz campaign, 0 crashes) +- [x] Stage 34: Windows x86_64 native support (build, test, JIT, C API, release artifacts) - [x] Stages 35-41: Production hardening (crash safety, CI/CD, docs, API stability, distribution) - [x] Stages 42-43: Community preparation, v1.0.0 release - [x] Stages 44-47: WAT parser spec parity, SIMD perf analysis, book i18n, WAT roundtrip 100% - [x] Reliability: Cross-platform verification (50 real-world programs), JIT correctness (OSR, back-edge, guard pages) - [x] Phase 8: Real-world coverage (50 programs), WAT parity 100%, 5 JIT codegen fixes -- [ ] Future: SIMD JIT (NEON/SSE), Windows port, WASI P3/async +- [ ] Future: SIMD JIT (NEON/SSE), WASI P3/async ## Known Limitations diff --git a/src/wasi.zig b/src/wasi.zig index 414a1913..fb1bee23 100644 --- a/src/wasi.zig +++ b/src/wasi.zig @@ -612,6 +612,7 @@ pub fn fd_close(ctx: *anyopaque, _: usize) anyerror!void { const vm = getVm(ctx); const fd = vm.popOperandI32(); + // stdio fds: return SUCCESS without closing (matches wasmtime behavior) if (fd >= 0 and fd <= 2) { try pushErrno(vm, .SUCCESS); return; @@ -1057,6 +1058,7 @@ fn wasiTimestamp(fst_flags: u32, set_bit: u32, now_bit: u32, provided_ns: i64, f } /// Write a WASI filestat struct (64 bytes) from a portable file stat to memory. +/// Note: nlink is always 1 because std.fs.File.Stat does not expose link count. fn writeFilestat(memory: *WasmMemory, ptr: u32, stat: std.fs.File.Stat) !void { const data = memory.memory(); if (ptr + 64 > data.len) return error.OutOfBoundsMemoryAccess; @@ -1064,13 +1066,44 @@ fn writeFilestat(memory: *WasmMemory, ptr: u32, stat: std.fs.File.Stat) !void { // dev(u64)=0, ino(u64)=8, filetype(u8)=16, pad=17..23, nlink(u64)=24, size(u64)=32, atim(u64)=40, mtim(u64)=48, ctim(u64)=56 try memory.write(u64, ptr, 8, @bitCast(@as(i64, @intCast(stat.inode)))); data[ptr + 16] = wasiFiletypeFromKind(stat.kind); - try memory.write(u64, ptr, 24, 1); + try memory.write(u64, ptr, 24, 1); // nlink unavailable in portable Stat try memory.write(u64, ptr, 32, stat.size); try memory.write(u64, ptr, 40, wasiNanos(stat.atime)); try memory.write(u64, ptr, 48, wasiNanos(stat.mtime)); try memory.write(u64, ptr, 56, wasiNanos(stat.ctime)); } +/// Write a WASI filestat struct from a POSIX fstatat result (preserves nlink). +/// Used on non-Windows for path_filestat_get where fstatat is needed for symlink control. +fn writeFilestatPosix(memory: *WasmMemory, ptr: u32, stat: posix.Stat) !void { + if (comptime builtin.os.tag == .windows) @compileError("writeFilestatPosix not available on Windows"); + const data = memory.memory(); + if (ptr + 64 > data.len) return error.OutOfBoundsMemoryAccess; + @memset(data[ptr .. ptr + 64], 0); + try memory.write(u64, ptr, 8, @bitCast(@as(i64, @intCast(stat.ino)))); + const S = posix.S; + data[ptr + 16] = if (S.ISDIR(stat.mode)) + @intFromEnum(Filetype.DIRECTORY) + else if (S.ISLNK(stat.mode)) + @intFromEnum(Filetype.SYMBOLIC_LINK) + else if (S.ISREG(stat.mode)) + @intFromEnum(Filetype.REGULAR_FILE) + else if (S.ISBLK(stat.mode)) + @intFromEnum(Filetype.BLOCK_DEVICE) + else if (S.ISCHR(stat.mode)) + @intFromEnum(Filetype.CHARACTER_DEVICE) + else + @intFromEnum(Filetype.UNKNOWN); + try memory.write(u64, ptr, 24, @bitCast(@as(i64, @intCast(stat.nlink)))); + try memory.write(u64, ptr, 32, @bitCast(@as(i64, @intCast(stat.size)))); + const at = stat.atime(); + const mt = stat.mtime(); + const ct = stat.ctime(); + try memory.write(u64, ptr, 40, @bitCast(@as(i64, at.sec) * 1_000_000_000 + at.nsec)); + try memory.write(u64, ptr, 48, @bitCast(@as(i64, mt.sec) * 1_000_000_000 + mt.nsec)); + try memory.write(u64, ptr, 56, @bitCast(@as(i64, ct.sec) * 1_000_000_000 + ct.nsec)); +} + fn wasiFiletype(kind: std.fs.Dir.Entry.Kind) u8 { return switch (kind) { .directory => @intFromEnum(Filetype.DIRECTORY), @@ -1108,13 +1141,22 @@ pub fn path_filestat_get(ctx: *anyopaque, _: usize) anyerror!void { if (path_ptr + path_len > data.len) return error.OutOfBoundsMemoryAccess; const path = data[path_ptr .. path_ptr + path_len]; - _ = flags; - const stat = dir.statFile(path) catch |err| { - try pushErrno(vm, toWasiErrno(err)); - return; - }; - - try writeFilestat(memory, filestat_ptr, stat); + if (comptime builtin.os.tag == .windows) { + // Windows: Dir.statFile always follows symlinks (no lstat equivalent) + const stat = dir.statFile(path) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + try writeFilestat(memory, filestat_ptr, stat); + } else { + // POSIX: respect SYMLINK_FOLLOW flag via fstatat (preserves nlink, mode) + const nofollow: u32 = if (flags & 0x01 == 0) posix.AT.SYMLINK_NOFOLLOW else 0; + const stat = posix.fstatat(dir.fd, path, nofollow) catch |err| { + try pushErrno(vm, toWasiErrno(err)); + return; + }; + try writeFilestatPosix(memory, filestat_ptr, stat); + } try pushErrno(vm, .SUCCESS); } diff --git a/test/realworld/run_compat.py b/test/realworld/run_compat.py index 9780e261..514e2fa2 100644 --- a/test/realworld/run_compat.py +++ b/test/realworld/run_compat.py @@ -70,11 +70,16 @@ def main() -> int: if "hello_wasi" in name or name == "tinygo_hello": extra_args = ["arg1", "arg2"] if "file_io" in name: - guest_file = tmp_dir / "zwasm_test_file_io.txt" - guest_dir = "/sandbox" - wt_extra = ["--dir", f"{tmp_dir}::{guest_dir}"] - zw_extra += ["--dir", f"{tmp_dir}::{guest_dir}"] - extra_args = [f"{guest_dir}/{guest_file.name}"] + if sys.platform == "win32": + # Windows: map host temp dir to a stable guest path + guest_dir = "/sandbox" + wt_extra = ["--dir", f"{tmp_dir}::{guest_dir}"] + zw_extra += ["--dir", f"{tmp_dir}::{guest_dir}"] + extra_args = [f"{guest_dir}/zwasm_test_file_io.txt"] + else: + wt_extra = ["--dir", str(tmp_dir)] + zw_extra += ["--dir", str(tmp_dir)] + extra_args = [str(tmp_dir / "zwasm_test_file_io.txt")] wt_exit, wt_out, wt_err = run_process([wasmtime, "run", *wt_extra, str(wasm), *extra_args]) zw_exit, zw_out, zw_err = run_process([str(zwasm), "run", *zw_extra, str(wasm), *extra_args]) diff --git a/test/spec/run_spec.py b/test/spec/run_spec.py index de87de64..aee8ce86 100644 --- a/test/spec/run_spec.py +++ b/test/spec/run_spec.py @@ -23,7 +23,7 @@ import threading import time -ZWASM = "./zig-out/bin/zwasm" +ZWASM = "./zig-out/bin/zwasm.exe" if sys.platform == "win32" else "./zig-out/bin/zwasm" SPEC_DIR = "test/spec/json" SPECTEST_WASM = "test/spec/spectest.wasm" From 423f066a6aa8e5010c6c7ef3e3bb3088d5edbd6d Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sun, 15 Mar 2026 22:17:25 +0900 Subject: [PATCH 3/5] Clean up code quality: VEH constant, HostHandle, fd placeholder docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - guard.zig: replace magic -1 with named EXCEPTION_CONTINUE_EXECUTION - wasi.zig: simplify HostHandle.close() — remove unnecessary mutable copy for File (File.close takes self by value, Dir needs *Dir) - wasi.zig: document fd_renumber placeholder entries as never-accessed - wasi.zig: add doc comment on allocFd ordering constraint --- src/guard.zig | 3 ++- src/wasi.zig | 14 ++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/src/guard.zig b/src/guard.zig index 26246739..32f172f0 100644 --- a/src/guard.zig +++ b/src/guard.zig @@ -204,7 +204,8 @@ fn windowsHandler(info: *windows.EXCEPTION_POINTERS) callconv(.winapi) c_long { setWindowsPc(ctx, rec.oob_exit_pc); setWindowsReturnReg(ctx, 6); rec.active = false; - return @as(c_long, -1); // EXCEPTION_CONTINUE_EXECUTION + const EXCEPTION_CONTINUE_EXECUTION: c_long = -1; + return EXCEPTION_CONTINUE_EXECUTION; } fn resetAndReraise() void { diff --git a/src/wasi.zig b/src/wasi.zig index fb1bee23..0546363c 100644 --- a/src/wasi.zig +++ b/src/wasi.zig @@ -158,13 +158,10 @@ const HostHandle = struct { fn close(self: HostHandle) void { switch (self.kind) { - .file => { - var host_file = self.file(); - host_file.close(); - }, + .file => self.file().close(), .dir => { - var host_dir = self.dir(); - host_dir.close(); + var d = self.dir(); + d.close(); }, } } @@ -359,6 +356,7 @@ pub const WasiContext = struct { return .{ .fd = host.raw }; } + /// Allocate a new WASI fd. All preopens must be added before the first call. fn allocFd(self: *WasiContext, host: HostHandle, append: bool) !i32 { // Compute fd_base lazily (after all preopens are added) if (self.fd_base == 0) { @@ -2013,7 +2011,7 @@ pub fn fd_renumber(ctx: *anyopaque, _: usize) anyerror!void { } else { while (wasi.fd_table.items.len < idx) { wasi.fd_table.append(wasi.alloc, .{ - .host = .{ .raw = undefined, .kind = .file }, + .host = .{ .raw = undefined, .kind = .file }, // placeholder, never accessed .is_open = false, }) catch { new_host.close(); @@ -2073,7 +2071,7 @@ pub fn fd_renumber(ctx: *anyopaque, _: usize) anyerror!void { // Extend table to fit while (wasi.fd_table.items.len < idx) { wasi.fd_table.append(wasi.alloc, .{ - .host = .{ .raw = undefined, .kind = .file }, + .host = .{ .raw = undefined, .kind = .file }, // placeholder, never accessed .is_open = false, }) catch { posix.close(new_host); From 6528ce4175b8315d40db560b307a396054ada65a Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sun, 15 Mar 2026 22:24:36 +0900 Subject: [PATCH 4/5] Update docs for Windows support: embedding, security, roadmap, D129 - embedding.md: add .dll/.lib to lib output, cross-platform ctypes - security.md: add Windows x86_64 to spec test platform list - roadmap.md: mark Phase 15 (Windows) done, add v1.6.0 milestone - decisions.md: add D129 (Windows first-class support architecture) - memo.md: update current state and task for PR #8 review --- .dev/decisions.md | 47 +++++++++++++++++++++++++++++++++++++++++++++++ .dev/memo.md | 42 +++++++++++++++++++----------------------- .dev/roadmap.md | 20 +++++++++++--------- docs/embedding.md | 6 ++++-- docs/security.md | 2 +- 5 files changed, 82 insertions(+), 35 deletions(-) diff --git a/.dev/decisions.md b/.dev/decisions.md index f139294d..5aa0a308 100644 --- a/.dev/decisions.md +++ b/.dev/decisions.md @@ -363,4 +363,51 @@ Backward compatible — NULL config uses default allocator. **Precedents**: SQLite (`SQLITE_CONFIG_MALLOC`), Lua (`lua_newstate(alloc_fn, ud)`), jemalloc, mimalloc — all accept custom allocators from the host. +--- + +## D129: Windows First-Class Support — Platform Abstraction + +**Date**: 2026-03-15 +**Status**: Complete (PR #8, commit 48f68a7) +**Decision**: Add Windows x86_64 as a first-class target via platform abstraction +layer, without compromising Mac/Linux code quality. + +**Problem**: zwasm used POSIX APIs directly (mmap, mprotect, signals, fd_t). +Windows requires VirtualAlloc, VEH, HANDLE-based I/O. + +**Design**: + +1. **`platform.zig`** — Unified OS abstraction for page-level memory: + - `reservePages`/`commitPages`/`protectPages`/`freePages` (mmap ↔ VirtualAlloc) + - `flushInstructionCache` (sys_icache_invalidate / __clear_cache / FlushInstructionCache) + - `appCacheDir`/`tempDirPath` (cross-platform paths) + +2. **`guard.zig`** — OOB trap via VEH on Windows: + - POSIX: SIGSEGV signal handler modifies ucontext PC + - Windows: VEH handler modifies CONTEXT.Rip/Pc on EXCEPTION_ACCESS_VIOLATION + - Same recovery logic (JIT code range check → redirect to OOB exit stub) + +3. **`wasi.zig`** — HostHandle abstraction: + - `posix.fd_t` → `HostHandle { raw: Handle, kind: .file|.dir }` + - POSIX file ops (read/write/lseek) → `std.fs.File` methods + - `path_open`: Windows uses `Dir.openDir`/`createFile`; POSIX keeps `openat` + - `FdEntry.append` field for Windows O_APPEND emulation + +4. **`x86.zig`** — Win64 ABI support: + - SysV: RDI/RSI/RDX args, RDI/RSI caller-saved + - Win64: RCX/RDX/R8 args, RDI/RSI callee-saved, 32-byte shadow space + - Compile-time dispatch via `abiRegsArg()`/`abiVmArg()`/`abiInstArg()` + +5. **Test infrastructure** — bash → Python migration: + - All test runners rewritten in Python for cross-platform support + - bash wrappers retained for Mac/Linux backward compatibility + - `select.select()` → `queue.Queue` + threading (Windows stdio) + +**Scope**: x86_64 Windows only. ARM64 Windows deferred (no test hardware). + +**Trade-offs**: +- `writeFilestat`: nlink always 1 on portable path (std.fs.File.Stat lacks nlink) +- `path_filestat_get`: POSIX retains fstatat for SYMLINK_NOFOLLOW; Windows always follows +- Binary size/memory checks skipped on Windows CI (no strip/time -v equivalents) + Related: D126 (C API), D127 (conditional compilation), CW D110, cw-new D13. diff --git a/.dev/memo.md b/.dev/memo.md index ee7472f8..1aa6874a 100644 --- a/.dev/memo.md +++ b/.dev/memo.md @@ -4,38 +4,34 @@ Session handover document. Read at session start. ## Current State -- Stages 0-46 + Phase 1, 3, 5 complete. **v1.3.0 released** (tagged 7570170). -- Spec: 62,263/62,263 Mac+Ubuntu (100.0%, 0 skip). E2E: 792/792 (100.0%, 0 leak). +- Stages 0-46 + Phase 1, 3, 5, 8, 11, 15 complete. **v1.5.0** (tagged 48342ab). +- Spec: 62,263/62,263 Mac+Ubuntu+Windows (100.0%, 0 skip). E2E: 792/792. Real-world: 50/50. - Wasm 3.0: all 9 proposals. WASI: 46/46 (100%). WAT parser complete. -- JIT: Register IR + ARM64/x86_64. Size: 1.20MB stripped. RSS: 4.48MB. -- Module cache: `zwasm run --cache`, `zwasm compile` (D124). -- **C API**: `libzwasm.so`/`.dylib`/`.a` — 25 exported `zwasm_*` functions (D126). +- JIT: Register IR + ARM64/x86_64 (macOS, Linux, Windows x86_64). Size: 1.22MB stripped. +- **Windows x86_64**: First-class support (D129, PR #8). platform.zig abstraction, + VEH guard pages, Win64 ABI, HostHandle WASI, Python test runners, CI 3-OS. +- **C API**: `libzwasm` (.dll/.lib, .dylib/.a, .so/.a) — 25 exported functions (D126). - **Conditional compilation**: `-Djit=false`, `-Dcomponent=false`, `-Dwat=false` (D127). - Minimal build: ~940KB stripped (24% reduction). -- **Phase 8 merged to main** (d770bfe). Real-world compat: 50/50 (Mac+Ubuntu). -- **Phase 11 merged to main** (49f99e5). C API allocator injection (D128). -- **main = stable**: v1.5.0 tagged (48342ab). ClojureWasm updated to v1.5.0. +- **main = stable**: v1.5.0. ClojureWasm updated to v1.5.0. ## Current Task -**Fix JIT fuel bypass + PR #6 timeout merge** +**PR #8 Windows support review + merge** (branch: `fix/pr8-review-fixes`) +- [x] Code review: 26 files, platform.zig / guard.zig / wasi.zig / x86.zig / CI +- [x] Fix run_compat.py rust_file_io regression (guest path alias) +- [x] Fix path_filestat_get SYMLINK_NOFOLLOW restoration +- [x] Fix README Stage 33 duplicate, run_spec.py .exe detection +- [x] Code quality: VEH constant, HostHandle.close(), fd placeholder docs +- [x] Doc updates: embedding.md, security.md, roadmap.md, decisions.md (D129) +- [ ] Merge Gate (Mac + Ubuntu) +- [ ] Benchmark recording +- [ ] Push to PR branch → merge via GitHub + +### Pending: JIT fuel bypass + PR #6 timeout Checklist: `@./.dev/checklist-jit-fuel-timeout.md` PR review: `@./private/pr6-timeout-review.md` -### Phase A: Fix JIT fuel bypass (branch: `fix/jit-fuel-bypass`) -- [x] A1. Add `jitSuppressed()` — suppress JIT when `fuel != null` (6 locations in vm.zig) -- [x] A2. Test: infinite loop with fuel=1M terminates (`30_infinite_loop.wasm`) -- [ ] A3. Commit Gate: `zig build test` pass, spec/e2e/realworld/bench (running) -- [ ] A4. Merge Gate (Mac + Ubuntu) - -### Phase B: Merge timeout support (PR #6 + additions) -- [ ] B1. Apply PR #6 changes (TimeoutExceeded, deadline, consumeInstructionBudget) -- [ ] B2. Add `--timeout ` CLI option -- [ ] B3. Tests + verify with JIT enabled -- [ ] B4. Commit + Merge Gate -- [ ] B5. Comment on PR #6, credit DeanoC - ## Handover Notes ### JIT fuel/timeout suppression — current fix vs proper solution diff --git a/.dev/roadmap.md b/.dev/roadmap.md index b4302b91..373d8e9e 100644 --- a/.dev/roadmap.md +++ b/.dev/roadmap.md @@ -148,16 +148,17 @@ Largest technical challenge. **Gate**: SIMD bench faster than scalar. zwasm v2.0.0 candidate. -### Phase 15: Windows Port (3 days) +### Phase 15: Windows Port (3 days) — DONE (PR #8, D129) -- D## decision record (SEH, VirtualAlloc, CI strategy) -- Memory management OS abstraction (mmap → VirtualAlloc) -- Signal handler port (SIGSEGV → SEH) -- JIT W^X port (VirtualProtect + FlushInstructionCache) -- WASI filesystem Windows branch -- CI Windows job + release binaries +- [x] D129 decision record (VEH, VirtualAlloc, CI strategy) +- [x] Memory management OS abstraction (mmap → VirtualAlloc) — `platform.zig` +- [x] Signal handler port (SIGSEGV → VEH) — `guard.zig` +- [x] JIT W^X port (VirtualProtect + FlushInstructionCache) — `jit.zig` +- [x] WASI filesystem Windows branch — `wasi.zig` HostHandle abstraction +- [x] CI Windows job + release binaries — `ci.yml`, `release.yml` +- [x] x86_64 JIT Win64 ABI (RCX/RDX/R8, shadow space) — `x86.zig` -**Gate**: Windows x86_64 all tests pass. 3-OS support complete. +**Gate**: Windows x86_64 all tests pass. 3-OS CI complete. ### Phase 18: Book i18n + Lazy Compilation + CLI Extensions (3 days) @@ -195,7 +196,8 @@ JIT deferred to first call. Trampoline → direct jump patch. | **v1.3.0** | 1, 3 | Guard pages, cache, CI automation, ARCHITECTURE.md | | **v1.4.0** | 5, 8 | C API, conditional compilation, 50+ real-world, WAT parity | | **v1.5.0** | 11 | Allocator injection, C API config, embedding docs | -| **v2.0.0** | 13, 15 | SIMD JIT, Windows, 3-OS support | +| **v1.6.0** | 15 | Windows x86_64, 3-OS CI, platform abstraction | +| **v2.0.0** | 13 | SIMD JIT (NEON/SSE) | ## Benchmark History diff --git a/docs/embedding.md b/docs/embedding.md index c08e1ba4..69780cb3 100644 --- a/docs/embedding.md +++ b/docs/embedding.md @@ -63,7 +63,7 @@ defer module.deinit(); zwasm provides a C API via `include/zwasm.h`. Build the shared library: ```bash -zig build lib # produces libzwasm.so / libzwasm.dylib / libzwasm.a +zig build lib # produces libzwasm (.dll/.lib, .dylib/.a, or .so/.a) ``` ### Basic Usage @@ -182,7 +182,9 @@ released when the host no longer needs the module. ```python import ctypes -lib = ctypes.CDLL("./libzwasm.so") +import sys +_ext = {"win32": ".dll", "darwin": ".dylib"}.get(sys.platform, ".so") +lib = ctypes.CDLL(f"./libzwasm{_ext}") # Load module with open("module.wasm", "rb") as f: diff --git a/docs/security.md b/docs/security.md index 8697febe..add75b49 100644 --- a/docs/security.md +++ b/docs/security.md @@ -167,4 +167,4 @@ Full Wasm 3.0 compliance (9 proposals): - branch_hinting, multi_memory, relaxed_simd - function_references, gc -Spec test results: 62,263/62,263 (100.0%) on both macOS ARM64 and Ubuntu x86_64. +Spec test results: 62,263/62,263 (100.0%) on macOS ARM64, Ubuntu x86_64, and Windows x86_64. From b6a28d9194bde682122f1f2738b57d5799fe54d4 Mon Sep 17 00:00:00 2001 From: "Shota Kudo (chaploud)" Date: Sun, 15 Mar 2026 22:31:41 +0900 Subject: [PATCH 5/5] Fix bench scripts for macOS bash 3, record PR #8 benchmarks - record.sh/run_bench.sh: replace declare -A (bash 4+) with file-based storage and string matching for bash 3 compat - Record 58 benchmarks (29 uncached + 29 cached) for PR #8 --- bench/history.yaml | 64 ++++++++++++++++++++++++++++++++++++++++++++++ bench/record.sh | 31 +++++++++++----------- bench/run_bench.sh | 9 +++---- 3 files changed, 84 insertions(+), 20 deletions(-) diff --git a/bench/history.yaml b/bench/history.yaml index fc01cd8c..399d1724 100644 --- a/bench/history.yaml +++ b/bench/history.yaml @@ -2669,3 +2669,67 @@ entries: rw_cpp_string_cached: {time_ms: 8.7} rw_cpp_sort: {time_ms: 9.1} rw_cpp_sort_cached: {time_ms: 7.6} + - id: "pr8-windows" + date: "2026-03-15" + reason: "PR #8 Windows first-class support + review fixes" + commit: "6528ce4" + build: ReleaseSafe + results: + fib: {time_ms: 47.5} + fib_cached: {time_ms: 46.7} + tak: {time_ms: 5.9} + tak_cached: {time_ms: 6.4} + sieve: {time_ms: 4.4} + sieve_cached: {time_ms: 4.1} + nbody: {time_ms: 22.0} + nbody_cached: {time_ms: 22.2} + nqueens: {time_ms: 4.3} + nqueens_cached: {time_ms: 2.7} + tgo_fib: {time_ms: 32.1} + tgo_fib_cached: {time_ms: 32.1} + tgo_tak: {time_ms: 6.1} + tgo_tak_cached: {time_ms: 7.3} + tgo_arith: {time_ms: 2.5} + tgo_arith_cached: {time_ms: 2.4} + tgo_sieve: {time_ms: 7.8} + tgo_sieve_cached: {time_ms: 3.7} + tgo_fib_loop: {time_ms: 3.4} + tgo_fib_loop_cached: {time_ms: 2.4} + tgo_gcd: {time_ms: 2.1} + tgo_gcd_cached: {time_ms: 2.6} + tgo_nqueens: {time_ms: 40.7} + tgo_nqueens_cached: {time_ms: 40.6} + tgo_mfr: {time_ms: 45.9} + tgo_mfr_cached: {time_ms: 46.3} + tgo_list: {time_ms: 35.0} + tgo_list_cached: {time_ms: 34.5} + tgo_rwork: {time_ms: 6.4} + tgo_rwork_cached: {time_ms: 6.2} + tgo_strops: {time_ms: 31.1} + tgo_strops_cached: {time_ms: 31.1} + st_fib2: {time_ms: 866.8} + st_fib2_cached: {time_ms: 858.3} + st_sieve: {time_ms: 210.1} + st_sieve_cached: {time_ms: 176.4} + st_nestedloop: {time_ms: 3.2} + st_nestedloop_cached: {time_ms: 2.2} + st_ackermann: {time_ms: 2.3} + st_ackermann_cached: {time_ms: 4.6} + st_matrix: {time_ms: 275.6} + st_matrix_cached: {time_ms: 275.8} + gc_alloc: {time_ms: 5.3} + gc_alloc_cached: {time_ms: 5.4} + gc_tree: {time_ms: 25.6} + gc_tree_cached: {time_ms: 22.0} + rw_rust_fib: {time_ms: 40.2} + rw_rust_fib_cached: {time_ms: 33.4} + rw_c_matrix: {time_ms: 26.3} + rw_c_matrix_cached: {time_ms: 19.7} + rw_c_math: {time_ms: 61.3} + rw_c_math_cached: {time_ms: 62.7} + rw_c_string: {time_ms: 47.3} + rw_c_string_cached: {time_ms: 46.9} + rw_cpp_string: {time_ms: 7.3} + rw_cpp_string_cached: {time_ms: 7.8} + rw_cpp_sort: {time_ms: 6.8} + rw_cpp_sort_cached: {time_ms: 7.2} diff --git a/bench/record.sh b/bench/record.sh index c3ecd736..6014bd83 100755 --- a/bench/record.sh +++ b/bench/record.sh @@ -158,16 +158,15 @@ echo "" precompile_for_cache() { echo "Pre-compiling modules for cache..." rm -rf ~/.cache/zwasm/ - declare -A seen + local seen_list="" for entry in "${BENCHMARKS[@]}"; do IFS=: read -r _name wasm _func _args _kind <<< "$entry" if [[ -n "$BENCH_FILTER" && "$_name" != "$BENCH_FILTER" ]]; then continue; fi local wasm_path="$PROJECT_ROOT/$wasm" if [[ ! -f "$wasm_path" ]]; then continue; fi - if [[ -z "${seen[$wasm_path]+x}" ]]; then - seen["$wasm_path"]=1 - $ZWASM compile "$wasm_path" >/dev/null 2>&1 || true - fi + case "$seen_list" in *"|$wasm_path|"*) continue ;; esac + seen_list="${seen_list}|${wasm_path}|" + $ZWASM compile "$wasm_path" >/dev/null 2>&1 || true done echo "" } @@ -180,8 +179,8 @@ fi TMPDIR_BENCH=$(mktemp -d) trap "rm -rf $TMPDIR_BENCH" EXIT -declare -A BENCH_RESULTS # bench_name -> time_ms -declare -A BENCH_RESULTS_CACHED # bench_name_cached -> time_ms +RESULTS_DIR="$TMPDIR_BENCH/results" +mkdir -p "$RESULTS_DIR" "$RESULTS_DIR/cached" for entry in "${BENCHMARKS[@]}"; do IFS=: read -r name wasm func bench_args kind <<< "$entry" @@ -225,7 +224,7 @@ print(round(r['mean'] * 1000, 1)) if [[ -n "$time_ms" ]]; then printf "%8s ms\n" "$time_ms" - BENCH_RESULTS["$name"]="$time_ms" + echo "$time_ms" > "$RESULTS_DIR/$name" else echo "PARSE_ERR" fi @@ -280,7 +279,7 @@ print(round(r['mean'] * 1000, 1)) if [[ -n "$time_ms" ]]; then printf "%8s ms\n" "$time_ms" - BENCH_RESULTS_CACHED["$cached_name"]="$time_ms" + echo "$time_ms" > "$RESULTS_DIR/cached/$cached_name" else echo "PARSE_ERR" fi @@ -328,12 +327,12 @@ results: ENTRYEOF for key in "${BENCH_ORDER[@]}"; do - if [[ -v "BENCH_RESULTS[$key]" ]]; then - echo " $key: {time_ms: ${BENCH_RESULTS[$key]}}" >> "$ENTRY_FILE" + if [[ -f "$RESULTS_DIR/$key" ]]; then + echo " $key: {time_ms: $(cat "$RESULTS_DIR/$key")}" >> "$ENTRY_FILE" fi cached_key="${key}_cached" - if [[ -v "BENCH_RESULTS_CACHED[$cached_key]" ]]; then - echo " $cached_key: {time_ms: ${BENCH_RESULTS_CACHED[$cached_key]}}" >> "$ENTRY_FILE" + if [[ -f "$RESULTS_DIR/cached/$cached_key" ]]; then + echo " $cached_key: {time_ms: $(cat "$RESULTS_DIR/cached/$cached_key")}" >> "$ENTRY_FILE" fi done @@ -341,6 +340,8 @@ done yq -i ".entries += [load(\"$ENTRY_FILE\")]" "$HISTORY_FILE" rm -f "$ENTRY_FILE" -total_count=$(( ${#BENCH_RESULTS[@]} + ${#BENCH_RESULTS_CACHED[@]} )) -echo "Recorded entry '$ID' ($total_count benchmarks: ${#BENCH_RESULTS[@]} uncached + ${#BENCH_RESULTS_CACHED[@]} cached)" +uncached_count=$(find "$RESULTS_DIR" -maxdepth 1 -type f | wc -l | tr -d ' ') +cached_count=$(find "$RESULTS_DIR/cached" -type f 2>/dev/null | wc -l | tr -d ' ') +total_count=$(( uncached_count + cached_count )) +echo "Recorded entry '$ID' ($total_count benchmarks: $uncached_count uncached + $cached_count cached)" echo "Done. Results in $HISTORY_FILE" diff --git a/bench/run_bench.sh b/bench/run_bench.sh index 5a517365..ccb90726 100755 --- a/bench/run_bench.sh +++ b/bench/run_bench.sh @@ -34,15 +34,14 @@ precompile_for_cache() { echo "Pre-compiling modules for cache..." rm -rf ~/.cache/zwasm/ # Collect unique wasm files from BENCHMARKS - declare -A seen + local seen_list="" for entry in "${BENCHMARKS[@]}"; do IFS=: read -r _name wasm _func _args _kind <<< "$entry" if [[ -n "$BENCH" && "$_name" != "$BENCH" ]]; then continue; fi if [[ ! -f "$wasm" ]]; then continue; fi - if [[ -z "${seen[$wasm]+x}" ]]; then - seen["$wasm"]=1 - $ZWASM compile "$wasm" >/dev/null 2>&1 || true - fi + case "$seen_list" in *"|$wasm|"*) continue ;; esac + seen_list="${seen_list}|${wasm}|" + $ZWASM compile "$wasm" >/dev/null 2>&1 || true done echo "" }