By Christian Findlay

WebAssembly Target [WASM-TARGET]

osprey --target=wasm32 --compile emits a wasm32-wasip1 command module. The backend reuses Osprey's LLVM IR, compiles it with clang, and links it with wasm-ld, wasi-libc, and the portable Osprey runtime archive.

The portable language core runs under a WASI host. CI compiles and validates the hello fixture, runs it through Node's WASI host and the browser shim, then runs OSPREY_TARGET=wasm32 zsh crates/run_test_corpus.sh — the same corpus harness the native backend uses, pointed at the other code generator. It compiles every program under tests/ to wasm32, runs it under Node's WASI host, and compares stdout byte-for-byte to the same .expectedoutput golden the native run must match. Only an explicit compiler diagnostic that target `wasm32` does not support a named capability qualifies as SKIP; the exact 61 excluded programs and their diagnostic reasons are pinned in the skip manifest. Unexpected linker errors fail, as do other build or runtime errors. CI requires TEST_CORPUS_FAIL=0, TEST_CORPUS_GOLDEN_FAIL=0 and TEST_CORPUS_GOLDEN_MISSING=0, plus a golden floor so coverage cannot quietly shrink — 142 on wasm32 and 203 natively (crates/run_test_corpus.sh, OSPREY_GOLDEN_MIN). The floor ratchets up as goldens are added and is never lowered to turn a red build green.

The wasm runtime includes strings, persistent collections, JSON, file operations, random/input, test and coverage hooks, the effect-handler stack, profiler stubs, and the browser host bridge. It excludes fibers, sockets, HTTP/WebSocket, process APIs, terminal APIs, general FFI, and resumable effect continuations. The compiler rejects operations requiring unavailable runtime features before LLVM emission or linking, as specified by [WASM-TARGET-CAPABILITIES].

Target Triple [WASM-TARGET-TRIPLE]

The target is wasm32-wasip1 (the current spelling of wasm32-wasi). wasi-libc provides the C allocation, string, formatting, memory, and stdout functions used by the portable runtime. Browser execution uses the WASI Preview 1 shim in examples/wasm/wasi-shim.mjs.

Target-Neutral LLVM IR [WASM-TARGET-IR]

Osprey's textual IR contains no target triple or data layout; clang supplies both. Osprey int remains i64. Heap handles round-trip through i64, so a 32-bit wasm pointer is zero-extended when boxed and truncated when recovered. LLVM computes target-specific aggregate offsets.

ILP32 Width Rules [WASM-TARGET-WIDTH]

Three paths require an explicit target-independent ABI:

  1. String length and concatenation call osp_strlen, whose C implementation converts target size_t to Osprey int (int64_t).
  2. Integer formatting uses %lld, because wasm32 long is 32-bit while Osprey int is 64-bit.
  3. A returned Result<T, E> is repacked to the declared success-slot type so its discriminator and error-pointer offsets agree on both LP64 and ILP32.

WASI Entry Point [WASM-ENTRY]

wasi-libc's command startup calls __main_void. The driver appends a thunk that calls Osprey's i32 @main() and returns its status. The resulting module starts through _start under wasmtime, Node's WASI implementation, or the browser shim.

The driver:

  1. invokes clang with --target=wasm32-wasip1 -O2 -c;
  2. locates the WASI sysroot from OSPREY_WASI_SYSROOT, WASI_SDK_PATH, or the supported platform paths;
  3. invokes wasm-ld with crt1-command.o, the program object, libosprey_runtime_wasm.a, and wasi-libc.

OSPREY_WASM_CC, OSPREY_WASM_LD, and OSPREY_WASM_RUN override the clang, linker, and runtime commands. Direct linking does not require clang's wasm compiler-rt archive.

Portable Runtime Archive [WASM-TARGET-RUNTIME]

make _runtime_wasm cross-compiles the units named by WASM_RT_SRC and creates compiler/bin/libosprey_runtime_wasm.a. Static archive members are linked on demand, so a command-line program that does not use the browser bridge has no osprey_web imports.

Browser Host ABI [WASM-WEB-ABI]

The optional browser bridge passes NUL-terminated UTF-8 messages, conventionally JSON, across a coarse event/render boundary. Osprey declares:

extern fn osprey_web_render(message: string) -> int
extern fn osprey_web_command(message: string) -> int

compiler/runtime/web_runtime.c imports render(pointer) and command(pointer) from the osprey_web import module. The host must decode the message synchronously; each Osprey wrapper returns status 0.

A program may define the event entry point:

fn osprey_web_dispatch(message: string) -> int = 0

The linker always exports osp_alloc. When the dispatcher exists, it also exports osprey_web_dispatch. For projects, the driver discovers the flattened mangled function and emits a stable forwarding thunk. The host allocates a NUL-terminated message with osp_alloc, refreshes its view of memory.buffer, copies the bytes, and calls the exported dispatcher. At the JavaScript boundary the i64 allocation size and dispatcher status use BigInt.

Modules with a browser dispatcher retain their initialized globals after the WASI entry returns, so later events can read application state. The dispatcher must handle its own effects; a handler installed only during main cannot satisfy a later browser call. The compiler validates that the dispatcher and both browser imports have the signature (string) -> int and rejects incompatible signatures before LLVM emission.

Target Capability Checking [WASM-TARGET-CAPABILITIES]

Before LLVM emission, the compiler checks the complete program for unsupported target constructs, including inside otherwise unused helper functions. --check, --compile, --run, and --llvm enforce the same rules. Diagnostics name wasm32, the offending operation or construct, and the unavailable capability. Native debugger and profiler flags are rejected for this target, including with --check and --llvm.

Explicit resume, fibers/channels and their scheduling operations, process APIs, built-in HTTP/WebSocket operations, terminal APIs, and arbitrary host FFI are rejected at compile time. Runtime-provided browser bridge imports remain supported. File, random, and input operations use their existing portable runtime implementations; access still depends on the WASI host. Missing implementation must not be hidden by successfully emitting an artifact that only fails when another application links it. Adding support requires a working runtime implementation and target tests before removing a rejection.

Effect Support [WASM-TARGET-EFFECTS]

The handler-stack portion of effects_runtime.c is portable and is included in the wasm archive. Substituting handlers remain supported. Resumable continuations use pthreads and are compiled out under __wasm__; the compiler rejects explicit resume before LLVM emission, naming the unsupported target feature, rather than relying on undefined continuation symbols at link time.

A static effect is rewritten away before code generation and needs no continuation on any target, so it compiles here exactly as it compiles natively (STAGE-WASM). For a dynamic effect the WebAssembly stack-switching proposal specifies one-shot continuations only, so this target MUST reject an operation that resumes at compile time with the operation named — becoming accepted, with no change to user code, once stack switching is available — and MUST reject a multi-shot operation permanently (MULTI-WASM).

Memory Backend [WASM-TARGET-MEMORY]

The wasm archive contains memory_runtime.c, the same default allocator used by native --memory=default. General releases do not reclaim aliased values, but the compiler's proved-unique release hook frees uniquely consumed temporaries. Only --memory=default is supported. --memory=gc and --memory=arc are compiler errors because matching WebAssembly runtime archives are unavailable; the compiler never silently substitutes the default allocator. This validation also applies to --check and --llvm.

The native conservative collector is not in WASM_RT_SRC: it depends on native stack/register/data-segment scanning, setjmp, and pthread synchronization. Osprey also does not emit WebAssembly-GC reference types; its values live in ordinary wasm linear memory.

Verification

  • cargo test -p osprey-cli wasm::tests
  • make wasm
  • wasm-validate examples/wasm/build/hello.wasm
  • node scripts/wasm-smoke.mjs examples/wasm/build/hello.wasm examples/wasm/hello.expectedoutput
  • node scripts/wasm-browser-smoke.mjs examples/wasm/build/hello.wasm examples/wasm/hello.expectedoutput
  • OSPREY_TARGET=wasm32 zsh crates/run_test_corpus.sh (or make _test_wasm_goldens)

The CI wasm job runs the validate, Node-WASI, browser-shim, and golden-harness checks with a pinned WASI sysroot.

Every check that runs a module under node:wasi requires Node 24 or newer. Before 24 that host caches the module's memory backing store when the instance starts and never refreshes it after memory.grow, so each WASI call a growing module makes afterwards touches freed memory: on x86_64 a SIGSEGV inside node — no stderr, no wasm trap — and where the stale page is still mapped, the module's output is silently dropped instead. A twelve-line hand-written module that writes, grows and writes again reproduces it, so the constraint is the host's, not this target's. scripts/wasm-browser-smoke.mjs reads the memory afresh per call and runs on any supported Node, which is what makes it a second, independent oracle rather than a copy of the first.

[WASM-TARGET-NODE] The version rule lives in exactly one place: scripts/wasm-smoke.mjs re-executes itself under a sound interpreter when the one it was started with is too old — OSPREY_NODE when set, else the newest qualifying ~/.nvm install — and fails with the defect's own explanation only when no such interpreter exists. Every caller therefore invokes a plain node: the Makefile targets, the CI workflow, crates/run_test_corpus.sh and osprey-cli's wasm end-to-end test carry no version logic of their own, so none of them can drift from this one. A relaunched child is marked in its environment, so a second too-old hop fails loudly rather than recursing.