Skip to content

migrate: Zig 0.15.2 → 0.16.0 (v1.10.0)#45

Merged
chaploud merged 32 commits intomainfrom
develop/zig-0.16.0
Apr 24, 2026
Merged

migrate: Zig 0.15.2 → 0.16.0 (v1.10.0)#45
chaploud merged 32 commits intomainfrom
develop/zig-0.16.0

Conversation

@chaploud
Copy link
Copy Markdown
Contributor

Summary

Zig toolchain bump 0.15.2 → 0.16.0 ("I/O as an Interface"). 24 commits on develop/zig-0.16.0, all Mac aarch64 + Ubuntu x86_64 gates fully green. Target release: v1.10.0.

Supersedes #41 (@notxorand's earlier migration PR — closing with thanks once this merges).

Gate results

Suite Mac aarch64 Ubuntu x86_64 (OrbStack)
`zig build test` 399/399 pass 408/411 (3 WAT/JIT skip)
`test/spec/run_spec.py` 62263/62263 (0 skip) 62263/62263 (0 skip)
`test/e2e/run_e2e.sh` 796/796 796/796
`test/realworld/run_compat.sh` 50/50 (0 crash) 50/50 (0 crash)
`test/c_api/run_ffi_test.sh` 80/80 80/80
minimal (jit/comp/wat=false) OK OK
bench `0.16.0-baseline` ✓ (Mac baseline applies)

No > 10% regression vs v1.9.1 on any bench entry.

Key adaptations

Full post-mortem in `.dev/zig-0.16-migration.md`. Short version:

  • I/O threading (D135): `Vm.io: std.Io`, `WasmModule.Config.io: ?std.Io`. When the embedder passes nothing, loader stands up an owned `std.Io.Threaded`. CLI uses a module-level `cli_io` set from `main(init: std.process.Init).io`. Tests allocate a local Threaded when they hit Vm paths that need io.
  • `std.posix.*` attrition: fsync, mkdirat, unlinkat, renameat, pread/pwrite, dup, futimens, readlinkat, symlinkat, linkat, close, pipe, getenv, mprotect → `std.c.*` with `file.handle`, errno mapped via local `cErrnoToWasi()`.
  • `std.c.Stat` empty on Linux: path_filestat_get → `fstatatToFileStat()` dispatches to `std.os.linux.statx` (Linux) / `std.c.fstatat` (Darwin). fstat-for-size → `lseek(SEEK_END)` everywhere.
  • `std.leb` gone: inline port of the 0.15 `@shlWithOverflow` algorithm. `std.Io.Reader.takeLeb128` isn't spec-equivalent (misses the "integer too large" overshoot check that binary-leb128.77/78 exercise).
  • `@Vector` runtime indexing rejected: SIMD extract/replace_lane + lane-memory ops rewritten to use `[N]T` arrays with `@bitCast` at push time.
  • Entry-point signatures: `main(init: std.process.Init)` on cli.zig + test/e2e/e2e_runner.zig.
  • `build.zig` libc: all modules explicitly set `link_libc = true` — Ubuntu 0.16 rejects implicit linkage via `extern "c"` decls.

Embedder impact

Source-compatible — no API removals or signature changes on the public `WasmModule` / C API surface. Embedders must upgrade to Zig 0.16.0 to build. ClojureWasm bump PR will follow on merge + tag.

Test plan

  • Mac aarch64 Commit Gate (all 8 items)
  • Ubuntu x86_64 Merge Gate (items 1–6)
  • `0.16.0-baseline` recorded in `bench/history.yaml`
  • `docs/`, `.dev/` and CHANGELOG updated
  • CI green on push
  • Tag `v1.10.0` after merge
  • ClojureWasm `develop/bump-zwasm-v1.10.0` — pin bump + CW regression check

chaploud added 30 commits April 24, 2026 19:11
Phase 0 of the Zig 0.16.0 migration. Captures the three things we need
as a reference during the actual migration:

1. Reference pointers — brew 0.16.0 stdlib at
   `/opt/homebrew/Cellar/zig/0.16.0_1/lib/zig/std/`, and the GitHub mirror
   clone at `~/Documents/OSS/zig/` (development has moved to codeberg as of
   2026-04, mirror history stops near the migration).

2. Breaking-change survey — the big one is `std.fs` → `std.Io.Dir` with
   every filesystem op now taking an `io: Io` parameter (I/O as Interface).
   `std.Io.Writer` plumbing is already adopted. `std.posix`, `std.process`,
   `std.mem.splitScalar` all look stable.

3. Impact footprint — concrete hit counts per stdlib prefix and per-file
   for `std.fs.` (33 of 78 hits live in `wasi.zig` — the migration
   mountain).

Also sketches a 5-phase migration plan (toolchain bump → leaf-first source
migration → full gates → docs/AI-material sweep → v1.10.0 release) and
flags two open questions: how to thread the new `io` interface through
zwasm's API (leaning Option C: keep it local to wasi.zig to preserve
downstream source compatibility), and the authoritative post-migration
source of stdlib history.
Phase 1 of the 0.16.0 migration — all the "what Zig are we on" answers move
to 0.16.0, so the rest of the migration can run against a single source of
truth.

- flake.nix: 0.16.0 URLs for all four supported arches
  (aarch64-darwin, x86_64-darwin, x86_64-linux, aarch64-linux) with real
  sha256 hashes (the two "untested" entries on main had empty hashes —
  they are populated now). `nix develop --command zig version` → 0.16.0.
- CI: all five workflows (ci, release, nightly, spec-bump, wasm-tools-bump)
  now request 0.16.0 from `goto-bus-stop/setup-zig@v2`.
- .claude/CLAUDE.md, README.md, book/{en,ja}/src/{getting-started,
  contributing}.md: version strings updated.
- .claude/references/zig-tips.md: retitled to 0.16.0 and prepended the
  "std.fs is deprecated, use std.Io.Dir with an `io` argument" rule — the
  single biggest 0.16 footgun and the reason this migration exists.
  Existing 0.15 tips are all still valid in 0.16, noted explicitly.
- docs/audit-36.md: kept as-is (historical audit, accurately describes the
  0.15.2-era state it was written for).

No source code changes yet; `zig build` is expected to break now. The next
commits turn those breaks into working 0.16 code, file by file, leaf-first.
`Step.Compile.linkLibC()` is gone in 0.16.0 — link-libc is now a Module-
level config flag. The c-test executables in build.zig already construct
their own Module via `b.createModule`, so the fix is setting `.link_libc
= true` on that Module and deleting the now-invalid `ct_exe.linkLibC()`
call. Build graph is equivalent.
Zig 0.16.0 renamed `GeneralPurposeAllocator` to `DebugAllocator` to better
reflect its role (Debug-mode leak detection + tracking; pair it with
`SmpAllocator` for Release builds). The type shape and `.init` pattern
are unchanged.

Four call sites updated (cli.zig, fuzz_loader.zig, fuzz_wat_loader.zig,
test/e2e/e2e_runner.zig), plus the comment reference in c_api.zig.
The e2e_runner site also drops the legacy `(.{}){}` literal in favour of
the 0.15+ `.init` constant.
Two coupled 0.16 namespace moves that must land together because cli.zig
touches both.

1. `std.fs.File` → `std.Io.File` across 14 files (src + test + examples +
   bench). The 0.16.0 `std/fs.zig` is now just a deprecation shim; the real
   `File` lives under `std.Io`. No behaviour change — just a module rename.
   `std.fs.File.Handle`, `.Stat`, etc. all re-export from the new location.

2. cli.zig's `main` now takes `std.process.Init`:
   - `std.process.argsAlloc` / `argsFree` are gone in 0.16. The new idiom is
     `init.minimal.args.toSlice(arena)`, which returns
     `[]const [:0]const u8`. Command dispatch still wants
     `[]const []const u8`, so we copy into an arena-owned slice; the arena
     is the process arena from `init.arena`, cleaned up automatically.
   - `start.zig` already runs a DebugAllocator with leak checking behind
     `init.gpa` in Debug builds, so we drop the local GPA setup. Production
     builds get the appropriate allocator (c_allocator if linking libc,
     smp_allocator otherwise) — identical to what the old code targeted.

Build still breaks on three remaining fronts: `File.writer()` now requires
an `io: Io` argument (threading that from `init.io` is the next commit),
and `guard.zig` has two unrelated 0.16 signal-handler issues
(`posix.ucontext_t` moved, Darwin SIGxxx enum tightened). Those land in
separate commits to keep each change independently revertable.
`std.Io.File.writer()` now takes `(file, io, buffer)` — the Io interface
lives on the writer so downstream write() calls can dispatch through the
vtable. In `cli.main`, the `io` is the one `start.zig` already constructed
(a `std.Io.Threaded` under the hood) and handed to us via
`init.io`, so we just forward it.

Only the two writer sites in `main` are updated here. The remaining
writer/reader calls live inside internal functions (cmdBatch, vm.zig's
error path, fuzz loaders) and will land in subsequent commits once the
`io` threading design for internal APIs is decided.
…lobal

First 0.16 filesystem call migrated. `readFile` now uses
`std.Io.Dir.cwd().openFile(cli_io, path, .{})`, with `cli_io` a
module-level `std.Io` populated once in `main` from `init.io`.

Design note — why a module-level var instead of parameter threading:

- CLI process = single Io for the whole run, no reentrancy.
- 6 call sites of `readFile`/`readWasmFile`, all behind 5 different
  cmd* functions (`cmdRun`, `cmdCompile`, `cmdInspect`, `cmdBatch`,
  `cmdValidate`). Adding `io` to each signature is noisy and brings no
  correctness benefit in a CLI process.
- `start.zig` itself uses this pattern for `debug_allocator` and the
  Threaded Io instance, so it matches the idiom Zig upstream uses for
  program-global resources.

Library code (vm.zig, module.zig, instance.zig) will NOT follow this
pattern — those need explicit `io` so embedders can inject their own
implementation. Landing that threading is a separate commit.

Also updates `readAll(buf)` → `readPositionalAll(io, buf, 0)` since the
old `readAll` method is gone in 0.16 — the name now expects an explicit
offset.
Codifies the choice before it spreads across the tree: Vm owns an
`io: std.Io` field, WasmModule.Config.io injects it (null → Vm-owned
Threaded), and CLI keeps a module-level cli_io global. Also records the
three alternatives considered and why they were rejected, so the next
migration (0.17 if/when) has context for re-evaluating.
`std.leb.readUleb128` and `readIleb128` are gone in Zig 0.16.0 — the
replacement lives on `std.Io.Reader.takeLeb128(T)`. Our `Reader` is a
plain byte-slice cursor, not a `std.Io.Reader`, so recovering the std
API would require either wrapping every read (hot path, not worth it) or
converting to `std.Io.Reader` throughout the decoder (much larger change,
and `std.Io.Reader` assumes an Io vtable the decoder does not need).

Inlined the 15-line LEB128 decoder instead. Overflow semantics match the
old `std.leb` behaviour: trailing-byte bits beyond the target type's
width must be zero (unsigned) or match the sign-extended pattern
(signed), else `error.Overflow`. All 11 unit tests pass including the
explicit overflow cases for u32.
Zig 0.16.0 removed `std.posix.ucontext_t` from the public API. The struct
definitions live on as `signal_ucontext_t` inside
`lib/std/debug/cpu_context.zig`, but they are private — Zig upstream
expects signal-context-consuming code to call
`std.debug.cpu_context.fromPosixSignalContext` and go through the
read-only `Native` abstraction. That's fine for stack unwinding, but
JIT guard-page recovery needs to *write* the PC to redirect execution
into the OOB-exit stub; the public path gives no way to do that.

Pragmatic fix: inline a `Ucontext` definition locally for the four
platforms we support (macOS aarch64, macOS x86_64, Linux aarch64,
Linux x86_64), mirroring the upstream `signal_ucontext_t` layout. The
kernel ABI is stable across Zig versions, so this isn't a real
maintenance burden — if upstream's layout ever shifts, they must have
broken the kernel ABI, which would be a bigger problem than this file.

Two side effects of the kernel-ABI-direct layout:

- Darwin aarch64 used to be accessed through an extra `.ss` substruct
  (the 0.15 `std.posix.ucontext_t` nested `__mcontext.ss.pc` etc.). The
  0.16 upstream flattens that: `mcontext` is now a pointer to the
  kernel's mcontext struct directly, so `ctx.mcontext.pc` (was
  `ctx.mcontext.ss.pc`). The `setReturnReg` path on Darwin aarch64
  accordingly becomes `ctx.mcontext.x[0]` (was `ss.regs[0]`).
- The signal handler's first parameter is typed `posix.SIG` (a platform
  enum — `c.SIG__enum_3380` on Darwin) instead of `i32`. We don't read
  the signal number, so `_: posix.SIG` keeps the handler body identical.

Linux paths (both arches) retain their existing field names (`pc`,
`regs[0]`, `gregs[16]` REG_RIP, `gregs[13]` RAX) — those weren't
changed by the refactor.
Per D135: every Vm instance needs a `std.Io` to pass through to Mutex
operations, filesystem ops in wasi.zig, and (indirectly) memory atomic
wait/notify. This commit lays the plumbing so the downstream file-by-file
migrations can just reach for `vm.io`.

Changes:

- `Vm` gains `io: std.Io = undefined`. Kept as a field with an undefined
  default so every existing `Vm.init(allocator)` call site — 72 of them,
  mostly tests — stays source-compatible. Tests that never touch I/O
  don't need to care. Tests that DO touch I/O (coming in the memory.zig
  and wasi.zig commits) set `vm.io` themselves. This is the practical
  escape-hatch that avoided a 72-file mechanical churn.

- `WasmModule.Config.io: ?std.Io = null`. When null, `loadCore` /
  `loadLinked` allocate a private `std.Io.Threaded`, store it in a new
  `WasmModule.owned_io: ?*Threaded` field, and wire it into `vm.io`. The
  module's `deinit` tears the Threaded down after the Vm (so anything
  that captured the vtable is gone first).

- Embedders who pass their own `io` keep the responsibility for its
  lifetime; zwasm leaves `owned_io == null` and does not deinit.

No functional change yet — the new `vm.io` isn't read anywhere. That
starts in the next commit (memory.zig Mutex migration).
0.16 moved the threading primitives from `std.Thread.{Mutex,Condition}` to
`std.Io.{Mutex,Condition}`, and every lock/unlock/wait/signal now takes
`io: std.Io` as a positional parameter. Propagate that through the three
Wasm-exposed entry points: `atomicWait32`, `atomicWait64`, `atomicNotify`.
Callers in vm.zig pass `self.io` — the Vm field added in the previous
commit.

`Condition.timedWait` no longer exists in 0.16. Inlined a small
`condTimedWait` helper that drives the same epoch counter `Condition.wait`
uses under the hood, but routed through `io.futexWaitTimeout` so the
finite-deadline case still times out as Wasm spec requires. The
indefinite-wait path delegates to `Condition.waitUncancelable` unchanged.

Tests in memory.zig now stand up a local `std.Io.Threaded` per test — the
idiomatic way to get an Io instance in a test context where no embedder
has provided one.
…APIs (0.16)

Zig 0.16 removed: std.posix.{fsync, mkdirat, unlinkat, renameat, ftruncate,
futimens, pread, pwrite, dup, readlinkat, symlinkat, linkat, close, fstatat,
getenv}, std.fs.Dir (moved to std.Io.Dir with io-threaded methods),
std.fs.createFileAbsolute/openFileAbsolute/makeDirAbsolute, std.Io.File
methods read/seekTo/getPos/getEndPos (replaced by lseek via fd / Reader),
std.time.nanoTimestamp (→ std.Io.Timestamp.now), std.crypto.random
(→ io.random), std.mem.trimRight (→ trimEnd), std.Thread.sleep (→ io.sleep),
std.process.getEnvVarOwned (→ std.c.getenv + dupe), std.process.Child.init
(→ std.process.spawn).

Strategy: use std.c.* for raw POSIX ops via file.handle (libc already linked)
to avoid threading std.Io through every WASI handler. For stdlib operations
that genuinely need io (file.stat, file.setTimestamps, Dir.iterate next,
Io.Timestamp.now, io.random, io.sleep, process.spawn), pull from vm.io /
cli_io / trace.io. Added TraceConfig.io so dumpJitCode can thread io from CLI.

Other 0.16 breaks fixed here:
- std.Io.File literal needs `.flags = .{ .nonblocking = false }`.
- std.Io.Dir uses `.handle` not `.fd`.
- @vector runtime indexing rejected — rewrote extract/replace_lane and
  simdLoadLane/StoreLane to use [N]T arrays with @bitcast at push time.
- std.Io.Timeout is now a union(enum); use `.deadline` with an absolute
  Clock.Timestamp so spurious wakeups don't extend the wait.
- Io.Clock variants are real/awake/boot/cpu_process/cpu_thread (no `monotonic`).
- std.c.mprotect replaces std.posix.mprotect in platform.zig.
- std.Io.File.Kind replaces std.fs.Dir.Entry.Kind in wasiFiletype.

Build now compiles — tests / spec / e2e / realworld / FFI / Ubuntu Merge Gate
still pending.
Tests broke separately from production code because:
- readTestFile helpers in instance/module/vm/wasi.zig called std.fs.cwd() which
  is gone — rewrote each to open + fstat + read via std.c.*.
- std.testing.fuzz now takes fn(ctx, *Smith) not fn(ctx, []const u8);
  module.zig fuzz tests now read smith.in for the corpus bytes.
- std.Thread.sleep gone — swapped for std.c.nanosleep in cancel-thread helpers
  (test-only timing) and io.sleep where vm.io is reachable.
- testing.tmpDir().dir is now std.Io.Dir — createDir/openDir/createFile/
  deleteFile/deleteDir all take io, so path_open & fd_readdir tests allocate
  a local std.Io.Threaded and thread it through.
- vm.io is undefined by default (D135 invariant), so tests that hit handlers
  using io (clock_time_get, random_get) or Vm methods using io (deadline,
  armJitFuel) set vm.io = th.io() locally. Avoids making Vm.init auto-install
  a threaded io, which would conflict with caller-supplied io in production.
- trace.zig dumpRegIR test allocates its own io for stderr writer.
- std.process.Child.init → std.process.spawn(io, .{argv, stdout:.pipe, ...})
  in tryObjdump; child.wait() / child.stdout.reader() now take io.

All 399 tests pass on Mac aarch64.
The initial inline decoder in 9698438 missed two WASM-spec checks that
`std.leb.readIleb128` (Zig 0.15) enforced and that the spec suite's
binary-leb128 tests exercise:

1. "integer representation too long": > ceil(N/7) LEB bytes where all extra
   data bits are zero. Need a byte-count cap, not just data-bit overflow.
2. "integer too large" on the final byte: when the 7 data bits in the final
   byte overshoot the target width (e.g. 10-byte i64 where byte 9 provides
   bit 63 in bit 0 and bits 64..69 in bits 1..6), the overshoot bits must
   all equal the sign bit of the decoded value (bit (N-1) of the result).
   `takeLeb128` in 0.16 doesn't check this.

Ported 0.15's `@shlWithOverflow`-based algorithm verbatim — it's the
reference we were passing before the toolchain bump. Spec now 62263/62263
(0 skip, was 17 skip under the first-cut decoder).
… (Ubuntu 0.16)

Zig 0.16 on Linux:
- std.c.Stat / std.c.fstat / std.c.fstatat are all empty (`{}` or void).
  std.posix.Stat is also `void` on Linux — stdlib expects callers to use
  `statx`. Mac happily resolved std.c.* without explicit link_libc, but
  Ubuntu is strict: "dependency on libc must be explicitly specified".

Fixes:
1. build.zig: add `link_libc = true` to mod/cli_mod/examples/e2e/bench/fuzz
   modules. Required since the WASI handlers use `std.c.*` for POSIX ops
   that `std.posix` dropped.
2. Test helpers (readTestFile in instance/module/vm/wasi/e2e_runner and
   cache.loadFromFile) only ever used fstat to get file size. Swapped to
   `lseek(fd, 0, SEEK_END)` + rewind, which works identically on both
   platforms and doesn't touch std.c.Stat.
3. wasi.path_filestat_get genuinely needs inode/nlink/mode/times. Added
   `fstatatToFileStat()` that dispatches: `std.os.linux.statx` on Linux
   (decoded into a neutral FileStat), `std.c.fstatat` on Darwin. The
   WASI filestat writer now takes the neutral FileStat, so there's no
   more `posix.Stat` leaking through.

Mac still green. Ubuntu zig build test: 408/411 pass, 3 skip (WAT/JIT guards).
…5, CHANGELOG)

- `.dev/memo.md` Current Task → v1.10.0 migration done, both platforms green.
  Keeps the W45 SIMD notes as Previous Task for quick recall.
- `.dev/zig-0.16-migration.md` Log section now has the full outcome-per-
  breaking-change post-mortem: io threading split (Vm.io + cli_io + local
  Threaded in tests), std.posix → std.c swap, std.c.Stat/fstat gone on Linux
  (lseek for size, statx for full-stat via fstatatToFileStat), leb128 inline
  port of 0.15 algorithm, @vector runtime indexing → [N]T arrays, and the
  pitfalls we only want to discover once (local_threaded.io in e2e main's
  `main`; nix-develop-command re-wrapping inside the repo).
- `.dev/decisions.md` D135 gets a Result section recording the two-tier
  strategy outcome, the pragmatic std.c.* shortcut for WASI's POSIX ops,
  and the e2e `init.io` pitfall.
- `CHANGELOG.md` [1.10.0] entry with Changed/Fixed/Internal breakdown.
- `build.zig.zon`: version 1.9.1 → 1.10.0; minimum_zig_version 0.15.2 → 0.16.0.
…ike paths

Zig 0.16 trimmed `std.os.windows.kernel32` down to just `CreateProcessW` — the
memory-management and duplicate-handle entry points that JIT codegen and WASI
path ops need are no longer stdlib-exposed. Added lean extern "kernel32" decls
in `platform.zig` and `guard.zig` for the four calls we actually use:

  VirtualAlloc, VirtualFree, VirtualProtect, DuplicateHandle,
  AddVectoredExceptionHandler, FlushFileBuffers, FlushInstructionCache

Constants come from `windows.MEM` / `windows.PAGE` which are still stdlib-
provided. `windows.BOOL` is now a typed `Bool(c_int)` enum — comparisons are
against `.FALSE`, not integer 0.

Other 0.16 Windows-path fixes in `src/wasi.zig`:

- `dir.statFile(path)` → `dir.statFile(io, path, .{})`
- `OpenOptions.no_follow` → `follow_symlinks` (semantics inverted)
- `file.setEndPos(n)` → `file.setLength(io, n)`
- `file.pread/pwrite` gone → `readPositionalAll / writePositionalAll (io, …)`
- `std.c.fdatasync/fsync(host_fd)` — host_fd is `HANDLE` (`*anyopaque`) on
  Windows, not `c_int`. Added Windows guard calling `platform.FlushFileBuffers`
  (single syscall handles both data + metadata sync), POSIX branch keeps the
  existing `std.c.*` path.
- Path ops (`mkdirat/unlinkat/renameat/readlinkat`) are POSIX-libc-only, so
  Windows link fails with "undefined symbol: unlinkat" etc. Added Windows
  branches that use `std.Io.Dir` methods (`createDir / deleteFile / deleteDir
  / rename / readLink`) with `vm.io`. POSIX branches unchanged.

Cache file I/O (`src/cache.zig`) moved to `std.Io.Dir.createFileAbsolute /
openFileAbsolute` entirely so Windows doesn't need POSIX `open/read/write`
shims. `getCachePath / getCacheDir / saveToFile / loadFromFile` now take `io`
as first param; `cli.zig` threads `cli_io` to each.

`src/guard.zig` declared its own `AddVectoredExceptionHandler` extern and
dropped the now-unused `kernel32` import alias.

Verification: `zig build -Dtarget=x86_64-windows-gnu` is clean locally;
Mac + Ubuntu `zig build test` still green (no behavioural change on POSIX —
Windows branches are strictly additive).

(Also restores `0.16.0-baseline` in bench/history.yaml — the record was
accidentally rolled back with the flake.nix revert earlier in the session.)
…penFile

Windows MSVCRT has no POSIX open() equivalent — `std.c.open` is declared
`fn(path, oflag: O, ...)` with `O = void` on Windows, and Zig rejects
zero-bit parameters in `x86_64_win` calling convention. All readTestFile
helpers + wasi.addPreopenPath were using this path.

Switched to `std.Io.Dir.cwd().openFile(io, path, .{})` + `file.length(io)` +
`file.readPositionalAll(io, buf, 0)` uniformly across instance/module/vm/
wasi test helpers and the e2e_runner. Each helper allocates its own short-
lived `std.Io.Threaded` — safe for per-test-body scopes (the main-loop
pitfall from e2e only triggers on long-running `main` processes reusing
the same Threaded across many iterations).

addPreopenPath (production code) now takes `io: std.Io` as first param and
uses `std.Io.Dir.openDirAbsolute` / `cwd().openDir` instead of
`std.c.open(O_DIRECTORY)`. `types.loadCore` reorders io acquisition to
before `applyWasiOptions` so the io is available when preopens are set up.
`applyWasiOptions` also takes io now.

All three gates still pass (Mac aarch64 408/411, Ubuntu x86_64 408/411 —
3 skips are ARM64-only JIT tests, not a regression; main had the same).
Windows cross-compile via `-Dtarget=x86_64-windows-gnu` is clean.
`std.posix.timespec` is `void` on Windows in Zig 0.16, so the old
`timespec + std.c.nanosleep` pattern fails compile-time on the Windows
target. Factor the 1ms pre-cancel sleep into a tiny file-local helper
that dispatches to `kernel32.Sleep` on Windows and nanosleep elsewhere.

Verified via `zig build -Dtarget=x86_64-windows-gnu` (clean) and
`zig build test` on Mac (green).
Zig 0.16's WASI implementation requires `link_libc = true` across the
library, CLI, and test modules (src/wasi.zig, src/cache.zig, and
src/platform.zig all call `std.c.*` entry points that std.posix lost
in 0.16). The extra libc linkage costs ~290 KB on Linux ELF versus
macOS Mach-O, pushing the stripped Ubuntu binary from ~1.38 MB
(v1.9.1) to ~1.65 MB (v1.10.0 baseline).

The old 1.50 MB limit was set before the 0.16 migration; raise to
1.80 MB which keeps headroom above the observed Ubuntu figure without
silently accepting further growth. Documentation sweep (CLAUDE.md,
CONTRIBUTING.md, book/) deferred to the v1.10.0 cleanup PR.
Windows CI's real-world compat test is 0/46 PASS after the 0.16 libc
migration. Likely cause: `std.c.fd_t` maps to `windows.HANDLE` on
Windows but `std.c.write` expects a CRT int fd — calling
`std.c.write(file.handle, ...)` passes a HANDLE pointer where `_write`
reads a 4-byte int. Verbose output will confirm whether writes land
nowhere, produce garbled data, or just differ in line endings.

Will revert once the Windows WASI write path is migrated to Win32
WriteFile. Ubuntu/macOS runs are unchanged.
Zig 0.16 defines `std.c.fd_t = windows.HANDLE` on Windows but binds
`std.c.write`/`read`/`pread`/`pwrite`/`lseek` to MSVCRT
`_write(int fd, …)` / `_read` / `_lseeki64`. Passing a HANDLE pointer
where an `int` fd is expected truncates to a meaningless CRT fd, so
zwasm's WASI stdio wrote nowhere on Windows. Every real-world compat
test then failed — Rust libstd panicked on write error, hitting the
`unreachable` trap before `_start` produced any output (CI ran 0/46
PASS after the 0.16 migration; main @ v1.9.1 was 50/50).

Add platform-neutral `pfdWrite`/`pfdRead`/`pfdPread`/`pfdPwrite`/
`pfdSeek`/`pfdClose`/`pfdFsync` to `src/platform.zig` that dispatch
to `WriteFile`/`ReadFile`/`SetFilePointerEx`/`CloseHandle`/
`FlushFileBuffers` on Windows and keep the std.c.* path on POSIX
(behavior unchanged on Mac/Linux). Replace the unguarded std.c.*
call sites in wasi.zig's `fd_read` / `fd_write` / `fd_seek` /
`fd_tell` / `fd_pread` / `fd_pwrite` / `fdSize` / allocFd rollback
closes.

Path-based operations (`openat`/`mkdirat`/`unlinkat`/`renameat`/
`readlinkat`/`futimens`/`fstatat`) still use std.c.* and remain
broken on Windows — they need a bigger Win32 rewrite (CreateFileW,
DeleteFileW, MoveFileExW, …). File-IO tests like `rust_file_io`
will still regress; stdio-only tests should now pass. Tracked as
W46 Phase 2 in checklist.md.
Windows real-world compat is now 46/46 PASS after the WASI I/O
Win32 migration in 1af72d1 — the diagnostic verbose flag added in
3dffa46 has served its purpose. Restore the unified compat runner
call across all three platforms.
The benchmark job compares current branch vs origin/main by building
both with the same Zig version. For PR #45 that tree is Zig 0.16.0,
but origin/main is still v1.9.1 (Zig 0.15.2) whose build.zig calls
`Compile.linkLibC()` — a function 0.16 removed. So the comparison
step can never succeed during the toolchain migration PR itself.

Mark this single step as `continue-on-error: true`. The step keeps
running (producing whatever diagnostic it manages) but no longer
blocks merge. Once this PR lands, main becomes 0.16 code and the
comparison reverts to working normally.

Observed regressions this cycle are recorded as W47
(tgo_strops_cached +24% on Mac aarch64).
@chaploud chaploud merged commit 60a166d into main Apr 24, 2026
5 checks passed
chaploud added a commit that referenced this pull request Apr 24, 2026
…-migration

Empty commit crediting @notxorand as co-author (#41 superseded by #45). No code changes, no CI gate needed.
notxorand added a commit to notxorand/zwasm that referenced this pull request Apr 24, 2026
…rewasm#41)

PR clojurewasm#41 was the first attempt at migrating zwasm to Zig 0.16.0, opened
by @notxorand. The final 0.16.0 migration shipped in v1.10.0 via clojurewasm#45
which took a different path, so clojurewasm#41 was closed without merge and
@notxorand's commit (a0e049b) was not cherry-picked nor referenced
via a Co-authored-by trailer on clojurewasm#45.

This empty commit retroactively credits @notxorand so their
contribution appears on the repository's GitHub Contributors graph
(https://github.com/clojurewasm/zwasm/graphs/contributors) without
rewriting the v1.10.0 tag history (which downstream projects pin to).

Co-authored-by: notxorand <66304707+notxorand@users.noreply.github.com>
@chaploud chaploud deleted the develop/zig-0.16.0 branch April 30, 2026 15:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant