Skip to content

Enable arm64_32-apple-watchos (Apple Watch SE2 / Series 4-8) build#97

Closed
matthargett wants to merge 1 commit into
clojurewasm:mainfrom
rebeckerspecialties:arm64_32-apple-watchos
Closed

Enable arm64_32-apple-watchos (Apple Watch SE2 / Series 4-8) build#97
matthargett wants to merge 1 commit into
clojurewasm:mainfrom
rebeckerspecialties:arm64_32-apple-watchos

Conversation

@matthargett
Copy link
Copy Markdown
Contributor

Summary

Makes `zig build static-lib -Dtarget=aarch64-watchos-ilp32 -Djit=false` produce a working `libzwasm.a` for the Apple Watch SE / SE2 / S4-S8 device class (the ILP32 ABI: 32-bit pointers, aarch64 instructions). All changes are gated on `@sizeOf(usize) < 8` (ILP32) so they are comptime no-ops on every 64-bit target.

Zig 0.16 spells the triple `aarch64-watchos-ilp32` — the legacy `arm64_32-` arch was removed in ziglang/zig#20820. The build was previously broken on ILP32 with five categories of errors; this PR fixes each in the minimum way that preserves behaviour on every other target.

Why

I run a 6-runtime pure-interpreter comparison (rebeckerspecialties/wasm-benchmark) targeting Apple's full first-party platform set (macOS, iOS, watchOS, tvOS). Apple Watch SE2 (S8 / Cortex-A53-derivative) ships as ILP32 — wasm-eligible since iOS 18 + watchOS 11, and one of the most common consumer-facing wasm-runtime deployment scenarios where pure interpretation is mandatory (App Store doesn't allow MAP_JIT outside of JavaScriptCore). zwasm's `-Djit=false` mode is a perfect fit for this; the missing piece was the ILP32 build itself.

Diff shape (5 changes, ~115 LOC net)

file change
`build.zig` Add `.single_threaded = true` to the static-lib module. Eliminates the wasm-threads atomic.wait/notify paths that pull in `std.Thread` / `std.Io.Threaded` — the latter doesn't compile under ILP32 (u64 syscall returns vs 32-bit usize narrowing errors in `dirReadDarwin` / `pwrite` etc.). Multithreaded consumers can still build the dynamic `shared-lib` variant.
`src/c_api.zig` Provide a self-contained `panic` namespace + a no-op `std_options.logFn`. Even with single_threaded, calls to `std.debug.simple_panic` would still pull `lockStderr → std_options.debug_io → debug_threaded_io.io()` into the call graph, and `std.log.defaultLog` does the same. zwasm already surfaces every error through the C-ABI `zwasm_last_error_message()` channel, so losing pretty panic messages on this target costs nothing the C consumer ever saw.
`src/guard.zig` Gate `GUARD_SIZE` and `TOTAL_RESERVATION` (4 GiB and 8 GiB) on `@sizeOf(usize) >= 8`. On 32-bit usize these comptime-overflow. Guard-memory is only used when `jitSupported()` returns true (false for watchos) so the placeholder zero values are unreachable at runtime.
`src/memory.zig` + `src/vm.zig` Cast `effective` (u64 from `@addWithOverflow`) to `usize` before using as an array index. After the preceding bounds check `effective` is known to fit in `usize` because `len`/`items.len` is `usize`. The explicit `@intCast` is a no-op on 64-bit and the correctness-preserving narrowing on ILP32.
`src/types.zig` Skip the auto-init of `std.Io.Threaded` in `loadCore` on 32-bit. The caller must supply `config.io` if they want WASI host directories on ILP32 — which they won't, because WatchKit apps don't get filesystem access. Non-WASI workloads never deref the io vtable, so leaving it default-init'd is safe on watchos.

Verified

  • `zig build static-lib -Dtarget=aarch64-macos -Djit=false` — still works (no diff)
  • `zig build static-lib -Dtarget=aarch64-ios -Djit=false` — still works
  • `zig build static-lib -Dtarget=aarch64-watchos-simulator -Djit=false` — still works
  • `zig build static-lib -Dtarget=aarch64-watchos-ilp32 -Djit=false` — now builds, produces a 2.5 MB `libzwasm_zcu.o` (verified `arm64_32_v8` Mach-O via `file`)
  • WatchKit app linked against the resulting `libzwasm.a` runs on Apple Watch SE2 hardware: `zwasm init: ok`, and the 6-runtime benchmark suite measures zwasm rows with results matching Pulley / WAMR / WasmEdge / wasm3 on every workload it supports.

Known Zig 0.16 quirk (separate)

`zig build static-lib` for `aarch64-watchos-ilp32` correctly generates the `.o` but doesn't pack it into the `.a` (the resulting archive is 88 bytes, just the SYMDEF). Our build script works around this with `ar rcs` post-build. Probably a Zig bug to file separately, not in scope for this PR.

Environment

  • Zig 0.16.0 (Homebrew `/opt/homebrew/bin/zig`)
  • macOS 26 Tahoe host, Apple Watch SE2 deploy target (watchOS 11.0+)
  • zwasm @ HEAD of `main` (`c922e5d5 docs(README): announce v2 from-scratch rewrite at top`)

@chaploud
Copy link
Copy Markdown
Contributor

Thanks @matthargett for the Apple Watch SE / S4-S8 ILP32 enablement — the wasm-benchmark / pure-interpreter / App-Store-eligible use case is great motivation, and -Djit=false is exactly the right fit.

I've read through the diff carefully. The strategy makes sense: work around std.Io.Threaded's u64 → usize narrowing under ILP32 via (1) single_threaded, (2) panic / log namespace replacement, (3) skip the std.Io auto-init, (4) avoid the GUARD_SIZE / TOTAL_RESERVATION comptime overflow, (5) @intCast(usize) on effective after the bounds check.

To ship cleanly on main I stacked six follow-up commits aligning with project conventions. Because GitHub rejected the maintainer-can-modify push to your fork (403 from rebeckerspecialties/zwasm despite maintainerCanModify=true — looks like an org-side restriction we can't override), I opened #98 which is your branch (commit 3d563237 untouched, with full authorship attribution preserved) + the six cleanup commits on top:

  • fix(c_api): replaced the hand-rolled ~80-line panic namespace with std.debug.no_panic (Zig 0.16 ships exactly this for the same purpose); gated on @sizeOf(usize) < 8 so 64-bit C-API consumers keep stdlib-default panic/log behaviour.
  • fix(build): scoped single_threaded = true to target.result.ptrBitWidth() < 64 so 64-bit static-lib consumers don't silently lose memory.atomic.wait/notify.
  • fix(types): refuse loadCore early with error.MissingIo if WASI or timeout_ms is requested on ILP32 without an explicit config.io (deadline / preopen would otherwise dereference an undefined vtable).
  • docs(D139, W55): documented the support matrix, alternatives, and open follow-ups (Zig 0.16 .a packing bug, etc.) in .dev/decisions.md / .dev/checklist.md.
  • ci: added a build-only aarch64-watchos-ilp32 smoke job (GitHub runners have no watchOS SDK so it's source-rot detection only).
  • fix(types): renamed the new error from IlpRequiresExplicitIoMissingIo so the public cross-target error set doesn't advertise an ABI name.

#98 has been verified end-to-end on Mac aarch64 (7/7 Commit Gate + bench-quick) and Ubuntu x86_64 (6/6 Commit Gate). Once CI is green it will be squash-merged with Closes #97, so this PR will auto-close and your commit lands on main with credit intact.

If anything about the cleanup direction looks off — different error name, different gating predicate, anything — please drop a note here, on #98, or in a new issue / discussion. Happy to iterate, and thanks again for the contribution! 🙇

@chaploud chaploud closed this in #98 May 17, 2026
pull Bot pushed a commit to DaviRain-Su/zwasm that referenced this pull request May 17, 2026
…wasm#98)

Adds support for `zig build static-lib -Dtarget=aarch64-watchos-ilp32 -Djit=false` (Apple Watch SE / SE2 / Series 4-8, ILP32 ABI). zwasm's `-Djit=false` mode is the right fit for App-Store-eligible WatchKit apps where `MAP_JIT` is forbidden outside JavaScriptCore.

Strategy (per D139): work around Zig 0.16's `std.Io.Threaded` not compiling under ILP32 (u64 → usize narrowing) via comptime gates that keep 64-bit consumers byte-identical.

- `build.zig` — `single_threaded = true` on `lib_static_mod` gated to `target.result.ptrBitWidth() < 64`
- `src/c_api.zig` — `pub const panic = std.debug.no_panic` on ILP32, default elsewhere; same gating for `std_options`
- `src/types.zig` — refuse `loadCore` with `error.MissingIo` when WASI or `timeout_ms` is requested on ILP32 without an explicit `config.io`
- `src/guard.zig` — `GUARD_SIZE` / `TOTAL_RESERVATION` set to 0 placeholders on ILP32 (comptime overflow otherwise; guarded paths predicated on `jitSupported()` which is false for watchos)
- `src/memory.zig`, `src/vm.zig` — explicit `@intCast(usize)` on `effective: u64` after the bounds check (no-op on 64-bit, narrowing on ILP32)
- `.github/workflows/ci.yml` — build-only smoke job for `aarch64-watchos-ilp32` on `macos-latest`
- `.dev/decisions.md` (D139), `.dev/checklist.md` (W55) — support matrix, alternatives, open follow-ups (Zig 0.16 `.a` packing bug)

Verified: Mac aarch64 Commit Gate 7/7 (incl. bench-quick), Ubuntu x86_64 Commit Gate 6/6, full CI 11/11 across macOS / Ubuntu / Windows + ILP32 smoke.

Closes clojurewasm#97.

Co-authored-by: Matt Hargett <plaztiksyke@gmail.com>
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.

2 participants