Viscosity
Review
Part X · Drip in Rust · Day 42

168. What Rust bought us

drip-rs is finished: the same output as the TypeScript VM, byte for byte, about four times as fast, and nearly twice as long. Before Part XI moves on, an honest account of what Rust forced, what it caught, what it let through, and what it cost.

About 15 minutes, including the exercises and quiz
By the end you will be able to
  • List the designs ownership forced on drip-rs, and why each was needed.
  • Tell the bugs Rust caught at compile time from the ones it could not see.
  • Count what the port cost in lines, and say what the count does and doesn’t mean.
  • State drip-rs’s speed-up with its scope and caveats.

The port is done

Part X set a goal in chapter 145: the same output, the same error messages on the same lines, the same exit codes as the TypeScript VM, for all 246 corpus programs. drip-rs meets it, plus 95 programs with a collection before every allocation. It runs the benchmarks about four times as fast. Was it worth it? That depends on what you count, so this chapter counts everything it can.

Intuition

After a renovation you go through the bills. Some costs you chose, some the building code forced on you. Some problems the inspector caught before you moved in; one or two only showed up when you ran the taps. And the place is warmer. All of it goes in the ledger.

Step throughThe ledger of the port
What Rust forced, caught and missed in drip-rs, its line counts, and its speed-upsWhat ownership forcedobjects in arenas, Gc<T> handlesroots passed in to collect()ip and Rc<Chunk> as registersmark bits outside the objects
Four designs the TypeScript VM never needed. Each one is how drip-rs satisfies “one owner, and one &mut or many &” for a heap of objects that point at each other.
1/5

What ownership forced

Four designs in drip-rs exist because of the borrowing rules, not because a VM needs them:

  • Arenas and handles. Objects that point at each other in any pattern can’t be references, so each kind lives in an Arena<T> owned by the heap, and links are Gc<T> indices (chapters 138, 153).
  • Explicit roots. The heap can’t borrow the VM that owns it, so the VM hands its roots to collect() as &dyn Trace (chapter 159).
  • Registers. The loop can’t hold &self.chunk while it pushes, so ip and an Rc<Chunk> live in the Vm itself (chapter 150).
  • Mark bits outside the objects. Marking writes and tracing reads; kept apart, they never fight over one borrow (chapter 159).

None of these is strange: clox, for one, keeps the running frame in a local variable for speed, which is the registers by another route[2]. But the TypeScript VM could skip all four, and Rust could not.

What it caught

The earlier chapters show the compiler’s refusals: a missing opcode arm (E0004, chapter 150), a class handle passed for an instance (E0308, chapter 145), a reference into a heap that might move (E0502, chapters 150 and 153), two mutable borrows of the compiler at once (E0499, chapters 155 to 158). Each was a compile error, found in seconds. The last two, in C, could have been a dangling pointer or two writers to one object, found when something broke.

At run time, every index is checked. A freed slot holds None, so a stale handle that finds it panics with a message, and #![forbid(unsafe_code)] means no line of drip-rs can turn either check off.

What it couldn’t catch

A Gc<T> is a number. If the VM forgets a root, the program compiles, and the collector frees an object that is still in use. In normal mode the arena reuses freed slots. A later object can move into the slot, and the stale handle reads it: no crash, no undefined behaviour, just the wrong answer. Run it:

Try itSafe, and still wrong: a handle that outlived its object
// A tiny arena that reuses freed
// slots, like drip-rs in normal mode.
struct Arena {
    slots: Vec<Option<String>>,
    free: Vec<usize>,
}

impl Arena {
    fn alloc(&mut self, s: &str)
        -> usize {
        let v = Some(s.to_string());
        if let Some(i) = self.free.pop()
        {
            self.slots[i] = v;
            return i;
        }
        self.slots.push(v);
        self.slots.len() - 1
    }
    fn free(&mut self, i: usize) {
        self.slots[i] = None;
        self.free.push(i);
    }
    fn get(&self, i: usize) -> &str {
        self.slots[i].as_deref()
            .expect("use after free")
    }
}

fn main() {
    let mut a = Arena {
        slots: vec![],
        free: vec![],
    };
    let name = a.alloc("fib");
    // The collector frees it: a root
    // was forgotten, so name lives on.
    a.free(name);
    let other = a.alloc("zoo");
    println!("{name} {other}");
    // No crash, no UB: just the wrong
    // object.
    println!("{}", a.get(name));
}
Open in Rust Playground ↗

That is why drip-rs has two test modes. Debug and stress modes never reuse a slot, so the stale read finds None and panics. And the 95 stress-GC programs collect before every allocation, so a missing root shows up at its first chance. Rust made the bug safe. Making it impossible took tests.

What it cost

I counted code lines in both: not blank, not comments, and for Rust, nothing from a module’s #[cfg(test)] on (2026-10-31, with a short script):

             TypeScript   Rust
compiler            537    804
vm                  362    611
heap / gc           144    348
object + value       55    236
scanner              80    209
everything else     186    355
total             1,364  2,563

Rust has 1.88× the lines and 1.53× the non-blank characters. Lines inflate because rustfmt wraps at 90 columns; the average Rust line has 22.6 non-blank characters, the TypeScript one 27.8. Some of the extra is not about ownership at all. value.rs reimplements JavaScript’s number printing, which the TypeScript VM gets from String(n). The opcodes need a table to decode bytes. And main.rs is a 50-line CLI with no twin in ts/src/vm. The rest is the price of arenas, handles and explicit roots, and of Result on every path that can fail.

There is also time to build: a clean release build with lto took 5.4 seconds here. The TypeScript VM has no build step.

What it bought

Speed, on the committed run: 3.31× to 5.68× the TypeScript VM on the nine timed programs, geometric mean 4.00×, and about 3.6× the work on zoo_batch. The caveats from chapter 167 stay attached: one TypeScript run against best of three, timers that start in different places, a shared VM.

Where did the speed come from? Not from skipping checks: chapter 166 measured four unchecked reads and found the gain inside the noise. The biggest single win was a design decision forced by the borrow checker, the registers. Without them fib ran 39.27 billion instructions instead of 20.19, and took twice as long.

Definition

The Rust trade-offs, as drip-rs paid them: memory safety without a garbage-collected runtime, compile-time checks on exhaustiveness, types and borrows, and roughly 4× the speed of the TypeScript VM; in exchange, designs built around single ownership (arenas, handles, explicit roots, registers), nearly twice the code, a build step, and logic bugs, like a missed root, that types can’t see.

Under the hood

Would a careful C port be faster still? Probably somewhat: no bounds checks, raw pointers instead of arena indices, clox’s own tricks. This book has no C implementation to measure, so it makes no claim. What it can say is that drip-rs got its speed with no unsafe, and that a stale handle panics or misreads instead of corrupting memory. The Rust book names the bargain: the compiler’s analysis is conservative, and it is “better for it to reject some valid programs than to accept some invalid programs”[1]. drip-rs met those rejections in every chapter of this Part. Part XI rebuilds Viscosity under the same rules.

Your turn

ExerciseCount the lines the way this chapter did

Write counts(src), which returns (code, test) for a Rust source file: the number of code lines before the module’s #[cfg(test)], and the number from that line on. Blank lines (only whitespace) and comment lines (starting with // after any indentation, which covers /// and //!) count as neither. The #[cfg(test)] line itself counts as a test line.

fn counts(src: &str) -> (usize, usize) {
    let n = src.lines().count();
    (n, 0)
}
Open in Rust Playground ↗

What you learned

  • Ownership forced arenas and handles, explicit roots, registers and separate mark bits.
  • The compiler caught exhaustiveness, handle-type and borrow errors. It couldn’t see a forgotten root: a safe, wrong read, which the stress-GC tests turn into a panic.
  • The port cost 1.88× the lines (1.53× the characters), partly from formatting and work JavaScript does for free.
  • It bought 3.31×–5.68× over the TypeScript VM in the committed run, most of it from design, not from removing checks.

Check your understanding

0/6
  1. 1.

    Which of these did Rust’s compiler not catch in drip-rs?

  2. 2.

    In normal mode, a stale handle reads a slot that a new object now uses. What kind of bug is that?

  3. 3.

    drip-rs has 1.88× the code lines of the TypeScript VM but 1.53× the non-blank characters. What explains most of the gap?

  4. 4.

    Which part of drip-rs has no counterpart in the TypeScript VM because JavaScript gives it for free?

  5. 5.

    What is the most defensible summary of drip-rs’s speed?

  6. 6.

    Which change bought the most speed in Part X’s own measurements?

References

  1. [1] Steve Klabnik, Carol Nichols, Chris Krycho (2026). The Rust Programming Language (3rd edition). No Starch Press; free online at doc.rust-lang.org/book. doc.rust-lang.org/book/ (checked 2026-10-31)
  2. [2] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)