Skip to content

Add support for wasm32-unknown-unknown - #728

Open
mrchantey wants to merge 4 commits into
DelSkayn:masterfrom
mrchantey:wasm32-unknown-unknown
Open

Add support for wasm32-unknown-unknown#728
mrchantey wants to merge 4 commits into
DelSkayn:masterfrom
mrchantey:wasm32-unknown-unknown

Conversation

@mrchantey

@mrchantey mrchantey commented Aug 19, 2026

Copy link
Copy Markdown

Description of changes

This engine is very useful as a sandbox, even in environments with an existing js engine like browsers and cloudflare workers.

I've used Fable to add support for wasm32-unknown-unknown. As an application layer guy the exact solution is a bit above my pay grade, but I'm happy to iterate on the PR to add tests and make changes as required, and welcome edits from maintainers.

Agent summary tldr

So the patch is: Reuse the wasi define set, point the compiler at a wasi-sysroot include tree, statically link the pure-compute members of wasi-libc (snprintf, strtod, libm, dlmalloc), and supply the OS tail from a ~110 line shim. No quickjs source is touched.

Full agent summary

Upstream PR

Branch wasm32-unknown-unknown on mrchantey/rquickjs, based on DelSkayn/rquickjs@edfc45c (version 0.12.2).

Command

Run from the repo root. The body is everything below the ## Body heading in this file.

gh pr create \
  --repo DelSkayn/rquickjs \
  --base master \
  --head mrchantey:wasm32-unknown-unknown \
  --title "feat: wasm32-unknown-unknown support" \
  --body "$(sed -n '/^## Body$/,$p' UPSTREAM_PR.md | tail -n +3)"

Title

feat: wasm32-unknown-unknown support

Body

Adds wasm32-unknown-unknown to the supported targets. A consumer adds rquickjs as a dependency and builds for the target: no feature flag, no extra crate, no wasi runtime, no local setup.

The resulting module imports exactly one host function, env.__rquickjs_host_now_us, and nothing else.

Why this is a small patch

Every ifdef this relies on already exists. The wasi support in this crate was lifted from javy and works by pretending to be emscripten (-DEMSCRIPTEN=1, plus FE_DOWNWARD/FE_UPWARD stubs, because wasi has no rounding-mode control). wasm32-unknown-unknown needs exactly the same defines. What it additionally needs is a libc, because unlike wasi the target ships none.

So the patch is: reuse the wasi define set, point the compiler at a wasi-sysroot include tree, statically link the pure-compute members of wasi-libc (snprintf, strtod, libm, dlmalloc), and supply the OS tail from a ~110 line shim. No quickjs source is touched.

What is in the patch

  • sys/build.rs — a target_arch == "wasm32" && target_os == "unknown" branch that pushes the emscripten defines, adds -isystem <sysroot>/include/wasm32-wasi, compiles the shim into libquickjs.a, and emits -L <sysroot>/lib/wasm32-wasi -lc.

  • Sysroot acquisition — mirrors the existing download_wasi_sdk(): curl + tar, guarded by existence checks. It fetches wasi-sysroot-24.0.tar.gz from the same wasi-sdk-24 release the crate already pins, and additionally verifies a pinned sha256 (a mismatch is a hard failure). It caches under $CARGO_HOME/rquickjs-wasi-sysroot rather than OUT_DIR, since the archive is 68MB and an OUT_DIR cache is per-profile and wiped by cargo clean. RQUICKJS_WASM_SYSROOT points at an existing sysroot and skips the download entirely.

    One header is then patched idempotently in place: wasi/api.h self-guards with #ifndef __wasi__ / #error, so that one #error is commented out. Defining __wasi__ instead would be simpler but is wrong — quickjs's __wasi__ path pins stack_limit = 0, disabling stack-overflow checking outright.

  • sys/wasm-shim/shim.c — the OS tail. A single host clock import backs gettimeofday/clock_gettime; localtime_r is UTC (no tz database exists here); abort/__assert_fail become __builtin_trap; the stdio surface quickjs's dump paths reach for becomes no-ops.

    The shim is compiled into libquickjs.a rather than its own archive, so lld resolves it to a fixpoint before it ever reaches wasi-libc, and each definition keeps the matching wasi-libc member out of the link.

  • sys/src/bindings/wasm32-unknown-unknown.rs — a copy of the wasip1 bindings. The layout is identical (same ILP32 wasm32 ABI), and the existing bindings_env!("TARGET") selection picks it up with no other change.

  • wasm-test/ — a cdylib plus a deno harness (wasm-test/runner.ts) that asserts the import list and runs a battery through the embedded engine.

Zero leaked wasi imports

A naive port leaks four: fd_write, fd_seek, fd_close, fd_fdstat_get. --why-extract traces all four to one chain — quickjs references printf, printf.o references __stdout_FILE, and stdout.o's FILE initialiser names __stdout_write / __stdio_seek / __stdio_close, with __stdout_write in turn reaching __stdio_write and __isatty.

Defining those five backends in the shim keeps those members unlinked, and the module's import list becomes exactly env.__rquickjs_host_now_us.

(Two deliberate non-shims, both commented in the source: fwrite, because musl's fwrite.o also defines __fwritex which the snprintf core needs, so a shim would collide; and __towrite, because __fwritex calls it to arm the write buffer of the stack FILE that snprintf builds — stubbing it silently corrupts every number-to-string conversion quickjs performs.)

set_max_stack_size caveat

JS_DEFAULT_STACK_SIZE is 1MB, which is exactly the size of the wasm shadow stack, so the default stack_top - 1MB wraps and stack checking is lost. Embedders must call Runtime::set_max_stack_size. This is documented in the README section added by this PR. If you would prefer it handled in-crate (defaulting the limit for this target in Runtime::new) I am happy to add that.

Verification

Built and run under deno 2.9.3 (rustc 1.95.0, clang 22.1.8). WebAssembly.Module.imports() reports one import. The battery covers classes and private fields, BigInt, unicode regexp and NFC normalization, promises and async/await with a microtask drain, exact float formatting (0.1 + 0.2, Number.MAX_VALUE, toFixed, toPrecision), Date formatting and Date.now(), and the three containment limits: a while (true) interrupted at a 200ms deadline, an allocation bomb contained by a 64MB memory limit, and deep recursion surfacing as a catchable RangeError under a 256KB max stack size. typeof fetch/typeof Deno/typeof require are all undefined inside the engine. All 32 cases pass.

The native workspace cargo check is unaffected; every change is behind the target check.

Checklist

  • Added change to the changelog
  • Created unit tests for my feature if needed

@richarddd richarddd left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can clean this up a bit. Also not a 100% sure i like that we're downloading sysroot in build.rs. What does other crates do? Either gate this in a feature flag (so its opt in) or put a script in scripts. I get that it's convenient but also a bit of a side effect and a fot gun.

@richarddd

Copy link
Copy Markdown
Collaborator

@Sytten toughts?

Build quickjs for the bare wasm target with no wasi runtime: reuse the
existing emscripten-flavored wasi defines, take headers from a pinned
wasi-sysroot, link wasi-libc's pure-compute members, and supply the OS
tail from a small shim.

The linked module imports exactly one host function,
`env.__rquickjs_host_now_us`.

- sys/build.rs: wasm32-unknown-unknown branch; sha256-pinned wasi-sysroot
  download cached under CARGO_HOME, overridable with RQUICKJS_WASM_SYSROOT
- sys/wasm-shim/shim.c: clock, UTC localtime_r, abort/assert traps, no-op
  stdio and stdio backends (the latter are what keep fd_write/fd_seek/
  fd_close/fd_fdstat_get out of the module)
- sys/src/bindings/wasm32-unknown-unknown.rs: identical ILP32 layout to
  the wasip1 bindings
- wasm-test/: cdylib + deno harness asserting the import list and a
  language/containment battery
@mrchantey
mrchantey force-pushed the wasm32-unknown-unknown branch from cebac5b to 4f4c5c2 Compare August 26, 2026 01:51
@mrchantey

Copy link
Copy Markdown
Author

Thanks for taking a look, I've pushed a rebase on master plus some cleanup:

  • the wasi/api.h patch no longer edits the sysroot in place. It writes a patched copy into OUT_DIR and puts that ahead of the sysroot on the include path, so a shared or system-wide sysroot is never modified
  • deduped the EMSCRIPTEN/FE_DOWNWARD/FE_UPWARD block
  • added a CI job that builds wasm-test and runs it under deno
  • noted in gen-bindings.sh that the bindings are a copy of the wasip1 ones

On the sysroot download, I held off rather than guess. My hesitation is that download_wasi_sdk already pulls the full ~150MB SDK for any wasip1/wasip2 build, so gating only this path would leave the wasm targets behaving differently from each other.

One option that might address both: the SDK already downloaded contains share/wasi-sysroot/include/wasm32-wasi/ and lib/wasm32-wasi/, the same layout as the standalone tarball. So wasm32-unknown-unknown could just reuse get_wasi_sdk_path() and drop the separate download, the checksum helper and the second env var entirely. One download path for all three wasm targets, WASI_SDK as the single override, and build.rs gets quite a bit smaller. Trade-off is 150MB instead of 68MB.

Happy to do that, or the feature flag, or move it to a script if thats preferable.

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.

Support for wasm32-unknown-unknown

3 participants