Zig instead of Emscripten
There was already a working answer. A 2025 attempt had
compiled the Ring virtual machine to WebAssembly with Emscripten, and it
ran — see 1+2 printed 3 in a browser. Replacing a
toolchain that works is the kind of decision that eats a week and produces
a rewrite of the same thing. Here is how it got made in half a day, from
evidence rather than taste, and what it actually cost.
Why the question was reopened at all
Not because Emscripten was slow or wrong. Because of what the old build was: a REPL demo with four structural defects, none of them fixable without rewriting the bridge anyway.
| Defect | What it meant |
|---|---|
| Batch, not resident | every call created and destroyed a whole RingState — no variable, class or loaded library survived a call |
| One-way, string-only | output escaped through an intercepted see into a fixed 512 KB static buffer, silently truncated; no structured return values, no way for Ring to call out |
No load, no files |
multi-file Ring could not be loaded at all — the browser has no filesystem |
| Fragile failure | a VM error could abort the whole wasm instance |
Since the bridge had to be rebuilt regardless, the toolchain was genuinely open — and every neighbouring project in the same family had moved to Zig: the CLI, the compiler, the engine, the packaging, and a vendored 9 MB SQLite amalgamation that Zig had compiled without a complaint. Zig 0.15 was installed on the machine. The Emscripten SDK was not.
The evidence, gathered by grep
Emscripten exists for good reasons, and the classic ones are specific:
setjmp/longjmp, pthreads, signals, and a POSIX
surface too wide for wasi-libc. The question was not "is Zig nicer"
but "does this particular C source need any of that?" — and 44
files of vendored VM source can simply be asked. These are the actual
commands, with the counts they still return today:
grep -rn "setjmp\|longjmp" ringvm/src/ # 0
grep -rn "pthread" ringvm/src/ # 0
grep -rn "signal(" ringvm/src/ # 1 — state.c, a SIGSEGV handler
grep -rln "dlopen\|LoadLibrary" ringvm/ # 1 — include/dll_e.h
grep -rln "fopen" ringvm/src/ # 4 files
Not a clean sweep, and that is the interesting part. The two hard blockers are genuinely absent. The two remaining hits are both in the corner a browser runtime does not use: a crash handler, and the macro that opens a dynamic library. Neither needed a workaround — one is emulated by a wasi-libc flag, the other is a whole source file that simply is not compiled.
The decision took the time it took to run five greps. What should have been an argument was a lookup.
Three years of "it probably needs Emscripten" collapsed into a page of
compiler flags. This is the whole of it — 41 of the 44 VM sources compiled
unmodified, and every environmental difference stated as a
-D:
"-DRING_NODLL=1", // a browser loads no extensions (drops dll_e.c)
"-DRING_LIMITEDSYS=1", // no system(), no chdir()/currentdir()
"-D_WASI_EMULATED_SIGNAL", // the SIGSEGV handler above
"-D_WASI_EMULATED_PROCESS_CLOCKS",
"-Dmkstemp(x)=(-1)", // WASI has no temp directory
"-Dfopen=rs_fopen", // every file open -> the embedded-map resolver
"-Dstat(a,b)=rs_stat(a,b)", // ...and every question ABOUT a file
The last two lines are the ones worth pausing on. There is no sandbox around the filesystem here — the filesystem is redirected at the compiler level to a read-only map baked into the binary. A capability that was never linked in cannot be escaped from, misconfigured, or re-enabled by a later patch.
And the stat line is a bug fix wearing a flag's clothes:
fexists, getpathtype and getfilesize
ask about a file rather than opening one, so redirecting only
fopen left them reporting that embedded files did not exist.
It is written function-like — stat(a,b), not stat
— so it expands only where stat is followed by a parenthesis,
leaving every struct stat in the source untouched.
What replaced each Emscripten convenience
Emscripten is not just a compiler; it is a bundle of conveniences. Each one had to be answered explicitly, and the answers turned out to be smaller than the conveniences.
| Emscripten gives you | What we wrote instead |
|---|---|
| generated JS glue and a POSIX-ish environment | a hand-written ~150-line WASI shim: clocks, randomness, fd_write — ours, vendorable, readable in one sitting |
MEMFS and --preload-file |
@embedFile: Ring sources baked into the wasm as data, with the bridge resolving load against an embedded map. Not a virtual filesystem — no filesystem |
EM_ASM for calling into JavaScript |
Zig extern imports declared by the bridge and provided by the same shim |
The fallback that was never needed
The plan wrote down its own escape hatch before starting: a timeboxed spike, and if a wasi-libc gap resisted a day's work, fall back to Emscripten for the VM only — the bridge API was designed to be toolchain-agnostic precisely so nothing downstream would care.
The gaps that actually surfaced were tmpfile and
mkstemp. Both were stubbed without touching a line of
vendor source. The escape hatch stayed shut, and the whole repair plan
— resident state, unbounded output, error trapping, the embedded payload,
the two-way bridge, and a version bump of the vendored VM — went through in
one session.
What it cost, honestly
Two things, and both are worth stating because a post that only lists wins is an advertisement.
The shim is ours to get right. Hand-written glue is the densest source of defects per line in this project, and its failures are the nasty kind: the VM keeps running and reports something plausible. A wrong clock conversion does not throw — it just makes every timestamp quietly wrong. So the shim has its own suite that holds it to the host's own clock, encoding and output ordering rather than to its opinion of itself: 20 checks, and they exist because guessing was not acceptable.
Some things had to be built rather than configured. No MEMFS meant writing a file-map resolver. No generated glue meant writing the imports. Each of these is a small amount of code you now own forever — which is a cost, and also the point.
What it bought
One toolchain across the whole family, so a person who can build
the CLI can build the runtime. No provisioning: no Python, no Node,
no emsdk environment scripts, no CMake. No dependency manifest —
there is no build.zig.zon in this repository, so nothing is
fetched at build time, ever. The dev server is part of the build. And the
artifact is small enough to argue about: 396,935 bytes of wasm for a
complete language implementation, released by default because the committed
artifact must never be a debug build.
zig build # the wasm, ReleaseSmall
zig build serve # ...and serve it on http://localhost:8377/
zig build dist # cross-compile the dev server for every shipped platform
Zig 0.15.2 is the only thing you need installed.
How we knew it was still Ring
Changing the compiler that builds a language implementation is exactly
the sort of move that produces something 99% correct — which is another way
of saying broken. The answer was not confidence, it was an oracle: every
program is run through both the wasm runtime and a native
ring.exe, and the output compared byte for byte.
| Corpus | Size | Result |
|---|---|---|
| Playground examples | 24 | byte-identical |
| the official Ring samples | ~284 | zero mismatches |
| code blocks lifted from the Ring documentation | ~550 | zero mismatches |
| permanent gates | 66 | all pass |
That battery is also what made it safe, later, to patch the VM's hottest code path — the two quadratic surprises are a story that only has a happy ending because ~850 programs could be re-checked in a few minutes.
The decision as it was written down at the time, evidence and fallback included: docs/REPAIR_PLAN.md, §2.5. The layers as they ended up: docs/architecture.md.