Project 6: A WASM Build

Compile Ember to wasm32-unknown-unknown and run a policy in a browser.

Effort: two days if the dependency rules were followed; a week if they were not. That difference is the project, and it is the cheapest possible audit of four months of discipline.


Why This Is the Audit

The dependency rules said the front end must never import vm, heap, engine, or stdlib. scripts/boundary-audit.sh checked it on every commit. This project is where you find out whether it was worth it.

rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release

Every error is a place where an OS dependency leaked in. The count is your score.


What Breaks, and What It Tells You

FailureWhat leakedFix
std::time::Instant not availableThe GC's pause timingInject a clock; Instant is not in wasm32's std
std::fsA module resolverFsResolver must be #[cfg(not(target_arch = "wasm32"))]
std::envSomething reading a variable — probably NO_COLORFeature-gate, or accept a config value
getrandomThe hash seedThe host supplies it; on wasm, from crypto.getRandomValues
Stack overflow at a lower depthwasm's default stack is ~1 MB, not 8 MBLower the limits, exactly as Lab 7 said
Larger-than-expected binaryPanic formatting, std::fmt, debug infopanic = "abort", opt-level = "z", wasm-opt

The fifth row is the interesting one. Lab 7 told you to choose MAX_CALL_DEPTH for a 2 MiB thread stack rather than the 8 MiB main stack. If you did, wasm works unchanged. If you tuned to 8 MiB, you get a stack overflow that wasm reports as an unhelpful trap — and you have just learned why that instruction was worded the way it was.


The Three Builds

1. The front end only

cargo build --target wasm32-unknown-unknown -p ember --no-default-features --features frontend-only

Lexer, parser, AST, compiler, disassembler, validator. No VM, no heap, no engine. If this builds, the dependency rules held.

This is genuinely useful on its own: a browser-based policy editor that lints, formats, and shows bytecode without running anything. That is a real product, and it exists because of a boundary you enforced for four months.

2. The full runtime

wasm-pack build --target web
import init, { EmberEngine } from './pkg/ember.js';
await init();

const engine = new EmberEngine({ instructions: 1_000_000, memory: 4 * 1024 * 1024 });
engine.execute(`
  function score(article)
    local s = article.semantic_score
    if article.age_hours < 6 then s = s * 1.4 end
    return s
  end
`);
console.log(engine.call("score", { semantic_score: 0.5, age_hours: 2 }));   // 0.7

The marshaling layer gains a JsValue ⟷ Value pair, which is the same marshaling problem with a different host — and it should reuse the same traits.

3. no_std + alloc

The strictest version, and the one that proves the most:

#![allow(unused)]
#![cfg_attr(feature = "no-std", no_std)]
fn main() {
extern crate alloc;
}

What breaks here is what genuinely depended on the OS, rather than on std's conveniences. If the core reaches no_std + alloc, it can run in an embedded target, a kernel module, or a constrained sandbox — and that is a much stronger claim than "it compiles to wasm".


Size

cargo build --target wasm32-unknown-unknown --release
wasm-opt -Oz -o ember.opt.wasm target/wasm32-unknown-unknown/release/ember.wasm
ls -lh ember.opt.wasm
twiggy top ember.opt.wasm | head -20      # what is actually taking space

twiggy is the tool that turns "it's big" into a list. The usual top entries in a Rust wasm binary are formatting machinery and panic infrastructure, and the usual fix is:

[profile.release]
opt-level = "z"
lto = true
codegen-units = 1
panic = "abort"        # ← but see below

Warning: panic = "abort" conflicts with the catch_unwind in register_function. On wasm, a host-function panic becomes a trap that kills the instance rather than an ErrorKind::Host. That is a real behavioral difference and it belongs in docs/limitations.md — not a footnote, because a JS host will experience it as "the engine silently died".

Record the number. A dynamic language runtime under a few hundred KB compressed is a genuinely useful artifact, and it is a direct consequence of an empty [dependencies].


Deliverables

  • cargo build --target wasm32-unknown-unknown succeeds for the full runtime.
  • A frontend-only feature building the front end without the VM, heap, or engine.
  • wasm-pack bindings with a JsValue marshaling layer reusing ToValue/FromValue.
  • A browser demo: a policy editor that lints, disassembles, and runs.
  • Every OS dependency behind #[cfg]; the clock and the RNG seed injected.
  • Limits verified against wasm's smaller stack; the depth limit tested there.
  • The binary size recorded, before and after wasm-opt, with a twiggy breakdown.
  • The panic = "abort" behavioral difference in docs/limitations.md.
  • Optional: no_std + alloc for the core, and a list of what had to change.
  • A written count: how many boundary violations did this find? That number is the audit result.

Where to Read

  • The wasm-bindgen guide, and wasm-pack's documentation.
  • twiggy for size profiling and the wasm-opt (Binaryen) flags.
  • fengari (Lua in JavaScript) and wasmoon (Lua compiled to WASM), for two very different approaches to the same goal — one a reimplementation, one a compilation.
  • The no_std chapter of the Embedded Rust Book, for what alloc-only actually costs.

Validation / Self-check

  1. How many boundary violations did the wasm build find? What does that number say about four months of boundary-audit.sh?
  2. Why is the front-end-only build a useful product on its own?
  3. Which limit had to change for wasm, and which lab's instruction made that unnecessary?
  4. What does panic = "abort" do to the host-function panic boundary, and where is that documented?
  5. What are the top three entries in your twiggy output, and what would removing each cost?
  6. What does reaching no_std + alloc prove that a wasm build does not?

Next: Project 7 — A Lua Compatibility Harness.