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
| Failure | What leaked | Fix |
|---|---|---|
std::time::Instant not available | The GC's pause timing | Inject a clock; Instant is not in wasm32's std |
std::fs | A module resolver | FsResolver must be #[cfg(not(target_arch = "wasm32"))] |
std::env | Something reading a variable — probably NO_COLOR | Feature-gate, or accept a config value |
getrandom | The hash seed | The host supplies it; on wasm, from crypto.getRandomValues |
| Stack overflow at a lower depth | wasm's default stack is ~1 MB, not 8 MB | Lower the limits, exactly as Lab 7 said |
| Larger-than-expected binary | Panic formatting, std::fmt, debug info | panic = "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 thecatch_unwindinregister_function. On wasm, a host-function panic becomes a trap that kills the instance rather than anErrorKind::Host. That is a real behavioral difference and it belongs indocs/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-unknownsucceeds for the full runtime. -
A
frontend-onlyfeature building the front end without the VM, heap, or engine. -
wasm-packbindings with aJsValuemarshaling layer reusingToValue/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 atwiggybreakdown. -
The
panic = "abort"behavioral difference indocs/limitations.md. -
Optional:
no_std+allocfor 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-bindgenguide, andwasm-pack's documentation. twiggyfor size profiling and thewasm-opt(Binaryen) flags.fengari(Lua in JavaScript) andwasmoon(Lua compiled to WASM), for two very different approaches to the same goal — one a reimplementation, one a compilation.- The
no_stdchapter of the Embedded Rust Book, for whatalloc-only actually costs.
Validation / Self-check
- How many boundary violations did the wasm build find? What does that number say about four months
of
boundary-audit.sh? - Why is the front-end-only build a useful product on its own?
- Which limit had to change for wasm, and which lab's instruction made that unnecessary?
- What does
panic = "abort"do to the host-function panic boundary, and where is that documented? - What are the top three entries in your
twiggyoutput, and what would removing each cost? - What does reaching
no_std+allocprove that a wasm build does not?