Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
375d0da
docs: seed .dev/zig-0.16-migration.md work log
chaploud Apr 24, 2026
2131fc8
chore: bump Zig toolchain pin 0.15.2 → 0.16.0
chaploud Apr 24, 2026
d7eea5b
build: move linkLibC from Compile step to Module option (0.16)
chaploud Apr 24, 2026
f5c76da
migrate: std.heap.GeneralPurposeAllocator → std.heap.DebugAllocator
chaploud Apr 24, 2026
fff3b70
migrate: std.fs.File → std.Io.File + main(init: std.process.Init)
chaploud Apr 24, 2026
cee1e80
migrate: thread init.io into stdout/stderr writer() calls in cli main
chaploud Apr 24, 2026
488ea51
migrate: cli.zig readFile — std.fs.cwd → std.Io.Dir.cwd with cli_io g…
chaploud Apr 24, 2026
6816e53
docs: add D135 — Io threading strategy for Zig 0.16.0
chaploud Apr 24, 2026
9698438
migrate: leb128.zig — inline LEB128 decode (std.leb removed in 0.16)
chaploud Apr 24, 2026
1ba37fd
migrate: guard.zig — local Ucontext struct + typed signal handler
chaploud Apr 24, 2026
22b8284
migrate: add io field to Vm + Config.io on WasmModule (D135 infra)
chaploud Apr 24, 2026
f1f2796
migrate: memory.zig — std.Io.Mutex/Condition via Vm.io
chaploud Apr 24, 2026
e0afccd
migrate: gc.zig — ArrayListUnmanaged init via .empty (0.16)
chaploud Apr 24, 2026
429534c
migrate: platform.zig — std.posix.PROT packed struct, std.c.mprotect …
chaploud Apr 24, 2026
6a27b3a
migrate: wasi.zig + friends — std.c.* replacements for removed posix …
chaploud Apr 24, 2026
1c1526d
migrate: test helpers + fuzz Smith API + tmpDir io threading (0.16)
chaploud Apr 24, 2026
550b0dc
fix(leb128): restore 0.15 stdlib overflow semantics for 0.16 inline port
chaploud Apr 24, 2026
2ae1962
migrate: e2e_runner — std.process.Init signature, std.c.* file reads …
chaploud Apr 24, 2026
bc35ffa
build(ffi): fall back to $CC when gcc is not on PATH
chaploud Apr 24, 2026
c35db78
chore(flake): add git to dev shell for bench/record.sh
chaploud Apr 24, 2026
e18346d
Revert "chore(flake): add git to dev shell for bench/record.sh"
chaploud Apr 24, 2026
258deca
Revert "build(ffi): fall back to $CC when gcc is not on PATH"
chaploud Apr 24, 2026
0da587b
fix(build): explicit link_libc + cross-platform fstat via lseek/statx…
chaploud Apr 24, 2026
1df937b
docs: finalize v1.10.0 Zig 0.16.0 migration (memo, migration log, D13…
chaploud Apr 24, 2026
25be605
fix(windows): 0.16 API shim for VirtualAlloc/DuplicateHandle, statx-l…
chaploud Apr 24, 2026
0296f39
fix(windows): replace std.c.open test helpers with std.Io.Dir.cwd().o…
chaploud Apr 24, 2026
fd02f20
fix(windows): comptime-branch 1ms sleep helper for cancellation tests
chaploud Apr 24, 2026
3550b4c
ci(size): raise stripped size guard 1.50 → 1.80 MB for 0.16 link_libc
chaploud Apr 24, 2026
3dffa46
ci(diag): enable compat --verbose on Windows (temp)
chaploud Apr 24, 2026
1af72d1
fix(windows): route WASI fd I/O through Win32 APIs, not std.c._write
chaploud Apr 24, 2026
7515048
ci(revert): drop Windows compat --verbose diagnostic
chaploud Apr 24, 2026
2944e6c
ci(bench): allow regression check to soft-fail across 0.15/0.16 boundary
chaploud Apr 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# zwasm

Standalone Zig WebAssembly runtime — library AND CLI tool.
Zig 0.15.2. Memo: `@./.dev/memo.md`. Roadmap: `@./.dev/roadmap.md`.
Zig 0.16.0. Memo: `@./.dev/memo.md`. Roadmap: `@./.dev/roadmap.md`.

## Language Policy

Expand Down
44 changes: 41 additions & 3 deletions .claude/references/zig-tips.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,44 @@
# Zig 0.15.2 Tips & Pitfalls
# Zig 0.16.0 Tips & Pitfalls

Common mistakes and workarounds discovered during development.
Common mistakes and workarounds discovered during development. Most entries
below carry over from 0.15.2 unchanged — 0.16.0's big shift is **"I/O as an
Interface"** (filesystem and I/O routines now take an explicit `io: Io`
argument); see the dedicated section below.

## Filesystem: std.fs is deprecated — use std.Io.Dir with an `io` argument

0.16.0 deprecates the entire `std.fs` module. `std.fs.cwd()`, `openFile`,
`readFileAlloc`, etc. now live on `std.Io.Dir`, and every method that performs
real I/O takes a second positional parameter of type `std.Io` (the interface
vtable). `std.fs.*` stubs still exist but forward to `std.Io.Dir` — prefer the
new path in new code.

```zig
// 0.15.2
const file = try std.fs.cwd().openFile(path, .{});

// 0.16.0
const file = try std.Io.Dir.cwd().openFile(io, path, .{});
```

Acquiring an `io` instance:

```zig
var threaded = std.Io.Threaded.init(allocator);
defer threaded.deinit();
const io = threaded.io();
```

On Linux you can swap `std.Io.Threaded` for `std.Io.Uring`; on macOS/BSD,
`std.Io.Kqueue`. `Threaded` is the portable default and what this project
uses in `wasi.zig`.

> **Common mistake**: writing `std.Io.Dir.cwd().openFile(path, .{})` without
> the `io` argument. The compile error is misleading ("expected 3 arguments,
> found 2") and doesn't name `Io` — if you see it, check the method signature
> in `lib/zig/std/Io/Dir.zig`.

## tagged union comparison: use switch, not ==

## tagged union comparison: use switch, not ==

Expand Down Expand Up @@ -31,7 +69,7 @@ try stdout.flush(); // don't forget

## Use std.Io.Writer (type-erased) instead of anytype for writers

In 0.15.2, `std.Io.Writer` is the new type-erased writer.
`std.Io.Writer` is the type-erased writer (landed in 0.15, finalized in 0.16).
`GenericWriter` and `fixedBufferStream` are deprecated.

Prefer `*std.Io.Writer` over `anytype` for writer parameters.
Expand Down
98 changes: 98 additions & 0 deletions .dev/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -645,3 +645,101 @@ throughput for hosts that never need cancel.
**Affected files**: vm.zig, types.zig, cli.zig, c_api.zig, include/zwasm.h,
test/c_api/test_ffi.c, docs/{embedding,errors,usage,api-boundary}.md,
book/{en,ja}/src/{c-api,embedding-guide}.md.

## D135: Io Threading Strategy for Zig 0.16.0 Migration

**Context**: Zig 0.16.0's "I/O as an Interface" shift routes every filesystem,
network, and synchronization primitive through a `std.Io` vtable. `std.fs.*` is
deprecated in favour of `std.Io.Dir`; `std.Thread.Mutex` moved to `std.Io.Mutex`;
`File.openFile`, `File.stat`, `File.close`, and friends all take `io: Io` as
their second positional argument. We cannot migrate to 0.16 without deciding
where `io` comes from at every call site — it cannot be recovered from thin
air, and constructing a fresh `std.Io.Threaded` per call is both wasteful and
semantically wrong (independent Io instances do not share resources).

**Decision**: Use a two-tier strategy, matching the two distinct lifecycle
regimes in the codebase.

1. **Library code owns an `io: std.Io` field on the Vm struct.**

`Vm` is the long-lived object that outlasts any individual invocation and is
already the owner of other execution-global state (fuel, deadline, cancel
flag). It becomes the natural owner of `io`. `Memory` and `WasiContext`
reach `io` via the `Vm` they belong to rather than holding their own copy —
keeps a single source of truth per module.

`WasmModule.Config` gains `io: ?std.Io = null`. When null, `loadCore`
constructs a `std.Io.Threaded` backed by the module's allocator, owns it,
and tears it down in `WasmModule.deinit`. Embedders who need a specific
Io implementation (`Uring` on Linux, `Kqueue` on macOS, or a mock for
tests) pass their own.

2. **CLI code uses a module-level `cli_io: std.Io` var.**

A CLI invocation is one process, one event loop, one Io. The `init.io`
handed to `cli.main` by `start.zig` lives for the whole run. Threading it
through the five `cmd*` functions plus all their helpers adds noise with
no correctness benefit — a CLI is single-threaded with respect to its
own I/O.

`start.zig` itself sets up its debug_allocator this way (module-level
`var`), so the pattern matches upstream idiom for single-process globals.

**Alternatives considered**:

- **Option A (thread `io` through all public API)**: Forces breaking changes on
every embedder (ClojureWasm + future consumers). Rejected — the benefit
(fine-grained per-call Io override) is not a use case we have.
- **Option B (mandatory `Config.io`)**: Forces embedders to construct an Io
before they can call `WasmModule.load`. Rejected — the common case is
"I just want the default behaviour" and we shouldn't make that verbose.
- **Option C (WASI-local io, construct inline in wasi.zig)**: Wastes resources
by constructing a fresh `Threaded` per WASI syscall and doesn't help
`Memory.Mutex` or `guard.zig`'s signal handler paths. Rejected once we
realised how many non-WASI sites also need `io`.

**C API implications**: `zwasm_config_t` does not expose `io` — C callers get
the default `Threaded` constructed internally. This is honest to the ABI (Zig
interface vtables do not cross FFI cleanly) and matches how `zwasm_config_t`
already handles Zig-only fields like `imports`.

**Affected files (migration-time)**: `src/vm.zig` (io field, init signature),
`src/types.zig` (`Config.io`, lifecycle), `src/memory.zig` (Mutex routes
through Vm.io), `src/wasi.zig` (33 `std.fs.*` sites), `src/cli.zig` (cli_io
module var — already in develop/zig-0.16.0), `src/module.zig`,
`src/instance.zig`, tests.

**Lifetime invariant**: `io` on Vm outlives every operation that captures it.
Since Vm owns any auto-constructed `Threaded` and tears it down last in
`deinit`, and since no invoke path holds `io` past the invoke's return,
there is no use-after-free path introduced by the threading.

**Result (2026-04-24)**: Migration shipped as v1.10.0. Strategy validated:

- Library: `Vm.io: std.Io = undefined`; `WasmModule.Config.io: ?std.Io = null`;
`loadCore` / `loadLinked` stand up an owned `std.Io.Threaded` (kept in
`WasmModule.owned_io`) when `io` is null, deinit'd last.
- CLI: `cli.cli_io: std.Io = undefined` set from `main(init: std.process.Init).io`
before any sub-command runs.
- Tests: each test that hits an `io`-using Vm path constructs its own local
`std.Io.Threaded` and assigns `vm.io = th.io()`.

**Pragmatic split from the original plan**: for POSIX ops that `std.posix`
dropped in 0.16 (fsync, mkdirat, unlinkat, renameat, pread/pwrite, dup,
futimens, readlinkat, symlinkat, linkat, fstatat, close, pipe, getenv,
mprotect), WASI handlers call `std.c.*` directly with `file.handle` rather
than threading `io` through. `file.handle` is trivially available from the
`HostHandle` / `FdEntry` structures and errno is mapped with a single local
helper (`cErrnoToWasi`). This keeps `io` as the currency for the std-Io-based
operations that genuinely need it (`file.stat`, `file.setTimestamps`,
`Dir.openDir`, `Io.Timestamp.now`, `io.random`, `io.sleep`,
`process.spawn`) while leaving the bulk of WASI's POSIX surface un-io-y.

**Pitfall noted**: in long-running executable `main()` functions (the e2e
runner in particular), constructing a fresh `std.Io.Threaded` locally and
using its `.io()` caused sporadic segfaults in `Io.Timestamp.now` after many
iterations — symptoms consistent with the Threaded scheduler being torn down
too early even though the variable was still in scope. Using `init.io`
(supplied by `start.zig`) avoids this entirely. Use init.io for top-level
binaries; use a freshly constructed Threaded only when the scope is bounded.

45 changes: 42 additions & 3 deletions .dev/memo.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,57 @@ Session handover document. Read at session start.

## Current State

- **Zig toolchain**: 0.16.0 (migrated from 0.15.2, 2026-04-24).
- Stages 0-46 + Phase 1, 3, 5, 8, 10, 11, 13, 15, 19, **20** complete.
- Spec: 62,263/62,263 Mac+Ubuntu (100.0%, 0 skip).
- E2E: 797/797 (Mac+Ubuntu). Fixed JIT memory64 bounds + custom-page-sizes 2026-03-25.
- Real-world: Mac 50/50, Ubuntu 50/50. go_math_big fixed 2026-03-25.
- E2E: 796/796 Mac+Ubuntu, 0 fail.
- Real-world: Mac 50/50, Ubuntu 50/50, 0 crash.
- FFI: 80/80 Mac+Ubuntu.
- JIT: Register IR + ARM64/x86_64 + SIMD (NEON 253/256, SSE 244/256).
- HOT_THRESHOLD=3 (lowered from 10 in W38).
- Binary: 1.29MB stripped. Memory: ~3.5MB RSS.
- Platforms: macOS ARM64, Linux x86_64/ARM64, Windows x86_64.
- **main = stable**. ClojureWasm updated to v1.5.0.
- **main = stable** (currently v1.9.1). v1.10.0 on `develop/zig-0.16.0`, awaiting PR.

## Current Task

**v1.10.0: Zig 0.16.0 migration — DONE on both platforms (2026-04-24)**

Full rundown in `@./.dev/zig-0.16-migration.md` (work log + D135 Io strategy).

- **Mac aarch64 gates**: 399/399 unit, 62263/62263 spec (0 skip), 796/796 e2e,
50/50 realworld, 80/80 FFI, minimal build OK, `0.16.0-baseline` bench
recorded (no >10% regression vs v1.9.1).
- **Ubuntu x86_64 gates** (OrbStack): 408/411 unit (3 WAT/JIT-guarded skips),
62263/62263 spec, 796/796 e2e, 50/50 realworld, 80/80 FFI, minimal build OK.
- **Branch**: `develop/zig-0.16.0` — 22 commits, ready for PR.
- **Remaining**: PR open, CI green, close notxorand's #41, tag + CW bump.

### 0.16 highlights we had to adapt to

- `std.process.Init` param on `main()` (args/gpa/arena/io/env from start.zig)
- `std.Io` threading — Vm gets an `io` field, stdlib methods take it as 1st arg
- `std.leb` gone → inline port of 0.15's `@shlWithOverflow` algorithm
(`std.Io.Reader.takeLeb128` is NOT spec-equivalent; misses the "integer too
large" overshoot check — see `binary-leb128.77.wasm`)
- `std.posix.*` attrition (fsync/mkdirat/dup/pread/etc.) — swap to `std.c.*`
- `std.c.*Stat` empty on Linux — fstatat replaced by `statx` via
`fstatatToFileStat()`; fstat-for-size replaced by `lseek(SEEK_END)`
- `@Vector` runtime indexing rejected → use `[N]T` arrays + `@bitCast`
- Decisions.md D135 covers the Io threading architecture.

### Hard-won nuggets (reuse later)

- **Do NOT wrap in `nix develop --command` inside this repo.** direnv +
claude-direnv has already loaded the flake devshell AND unset
DEVELOPER_DIR/SDKROOT. Re-entering nix shell re-sets SDKROOT and breaks
`/usr/bin/git`. See `memory/nix_devshell_tools.md`.
- **e2e_runner uses `init.io`, NOT a locally constructed Threaded io**.
A fresh `std.Io.Threaded.init(allocator, .{}).io()` inside user main
crashes with `0xaa…` in `Io.Timestamp.now` when iterating many files.

## Previous Task

**W45: SIMD Loop Persistence — DONE (2026-03-26)**

Q-reg/XMM cache now persists across loop iterations. Three techniques:
Expand Down
Loading
Loading