Crank, a WebAssembly interpreter written from nothing
Crank reads WebAssembly’s binary format, checks it, and runs it one instruction at a time, in plain JavaScript and without the browser’s own WebAssembly. It was written from an empty file and the specification, and it passes every test in the official suite it runs. Below, it runs four small programs where you can watch it, and every result is held against your browser running the same bytes.
·
Crank prints the listing from the bytes it runs instead of from the source, with the names stored in the module’s name section. The marked line is the instruction Crank takes next. When a run ends, your browser’s own WebAssembly runs the same bytes with the same arguments, and the two answers are set side by side.
Bytes first
A WebAssembly module starts with four bytes that spell \0asm and four more for the version. After them come sections, each an id, a length and its contents, in a fixed order: the types of the functions, what the module imports, its functions, tables, memory and globals, what it exports, and last the code and the data. Numbers are written in LEB128, seven bits to a byte with the eighth saying whether another byte follows, so a small number takes one byte and a large one takes more.
Crank’s decoder is strict where the specification is strict. A number may not use more bytes than its width needs, and the unused bits of its last byte must be zero, or repeat the sign. A name must be valid . Writing that last check turned up a trap in the platform rather than the format: JavaScript’s TextDecoder drops a byte order mark at the start of its input, so a name that begins with one came out shorter than its bytes. The test suite has exactly such a name, and Crank’s decoder now sets ignoreBOM on TextDecoder, so the mark stays in the name.
Checked before it runs
Before a single instruction runs, the whole module is validated. Crank walks every function once with a stack of types instead of values: i32.add must find two i32 on it and leaves one, a block must end with exactly the types in its signature, and a branch must leave on the stack the types of its target block. Code after an unconditional branch can never run, and there the stack becomes polymorphic, so any type it asks for is taken as given.
After validation, the interpreter runs without checks of its own. It never checks a type, never looks for a missing value, and never asks whether a branch target exists. Validation also writes down where every block’s else and end are, so a branch is a jump to a known place and not a search.
A stack of its own
A natural way to write an interpreter is to have a WebAssembly call become a JavaScript call. It is short, and it hands the depth of recursion to JavaScript, whose stack runs out long before WebAssembly’s should, with an error that names the wrong thing. Crank keeps its own stack of frames instead. A call pushes a frame with the function, its locals, the place to return to and its labels, and a return pops it, so the loop that runs instructions never calls itself.
That is also what makes stepping possible. Between any two instructions the whole state of the program is data: the values on the stack and the frames above them, which is what the panel beside the listing shows. A run is the same loop taking a few hundred thousand steps at a time and giving the page back between them. When a recursion goes too deep, Crank stops at a fixed number of frames and says the call stack is exhausted, as the specification asks.
A label is where a branch goes. A block is left forwards, at its end, and a loop is left backwards, at its start, so br to a loop is how WebAssembly repeats. A branch keeps as many values as its label takes, drops everything else down to the height the block started at, and goes on from there.
Numbers the way the machine has them
An i32 is a JavaScript number kept in range with | 0 and Math.imul. An i64 does not fit in a double, so it is a BigInt, wrapped to sixty four bits after every operation. An f32 is a double rounded with Math.fround after each step, which gives the right answer because a double has more than twice the precision of a float.
Two corners needed more. The first is NaN. WebAssembly says which bits a NaN has, and some instructions must keep them, but JavaScript has one NaN and forgets the rest. Crank carries a NaN with particular bits as an object that holds them and still compares as NaN. The second is turning a large i64 into an f32. Through a double, the number is rounded twice, and a value just above halfway between two floats can land exactly on halfway after the first rounding and go the wrong way on the second. Crank keeps one extra bit, set when anything was cut off, and rounds once.
Held against the specification
The WebAssembly specification comes with a test suite: scripts that define modules and say what calling them must return, where they must trap, and which modules must be refused as malformed, invalid or unlinkable. The command runs every script for the second version of the standard. WABT, the reference tools for WebAssembly, turns each module’s text into bytes, and Crank does the rest on its own.
$ node web/notes/crank/spec.mjs
89 scripts from the WebAssembly test suite, 27,831 commands
passed 27,259, failed 0, not run 572, in 9.5 s
passed, by kind of command:
21,453 assert_return
2,388 assert_trap
1,475 assert_invalid
1,126 module
719 assert_malformed
83 assert_unlinkable
15 assert_exhaustion
not run, and why:
570 malformed text
2 WABT left out the data count section
Nothing fails. What is not run is text that is malformed on purpose, which tests a text parser Crank does not have, and two modules where WABT writes the bytes without the section the test is about.
Held against
The four programs above, run again in Crank and in own WebAssembly from the same bytes. For the Mandelbrot set, every byte each row writes to memory goes into a fingerprint, so the same result means the same image.
$ node web/notes/crank/race.mjs node v22.22.0, 4 programs, each the same bytes in Crank and in V8 program result steps Crank V8 slower fib fib(24) 46368 1,650,535 67 ms 0.29 ms 230x fac fac(0..25), 1000 times -1307449385690794608 4,160,000 120 ms 1.37 ms 87x sieve sieve(1000000) 78493 40,564,220 1192 ms 2.84 ms 420x mandelbrot 320 by 180, at most 100 1399128 903779024 57,217,382 1605 ms 4.10 ms 391x the same results in 4 of 4
The answers are the same, and the times are far apart. compiles WebAssembly to machine code before it runs, and Crank looks at every instruction every time it passes, with a JavaScript value for each number on its stack. A first version was twice as slow again, because it looked the arithmetic up in an object whose keys run past the one byte opcodes, which keeps as a slow dictionary.
What it does not have
Crank runs the second version of WebAssembly without its vector instructions. It has no threads, no more than one memory, no tail calls, no exceptions and no garbage collected types, which are all later proposals. It reads the binary format only, so its tests and the four programs here are turned from text into bytes by WABT. It does not compile anything, which is why it is hundreds of times slower than the browser, and why every step can be watched.
The source is in the portfolio’s repository under web/notes/crank/: crank.js for the decoder, the validator and the interpreter, text.js for the listing, spec.mjs and race.mjs for the commands, and the programs as text under programs/. The test scripts are the WebAssembly specification’s, under the Apache License.