Skip to content

Memory Safety vs Rust

Where each language catches a problem. Every row is a probe built in both languages or kept as a Milo regression fixture. unsafe and FFI are trust boundaries in both.

RustMilo
Use-after-move, double freecompile timecompile timeeven
Use-after-free, owned datacompile timecompile timeeven
Returning a reference to a localcompile timecompile timeeven
Null dereferencecan't expresscan't expresseven
Iterator invalidationcompile timecompile timeeven
&mut aliasing &compile timecompile timeeven
Out-of-bounds indexruntimeruntimeeven
Divide by zero, INT_MIN / -1runtimeruntimeeven
Use-after-free through cyclic dataruntimeruntimeeven
Integer overflowruntime, debug builds onlyruntime, every buildMilo ahead
Contracts: requires / ensures / invariantruntime, unstablecompile time, for linear arithmeticMilo ahead
Reference stored in a structcompile timecan't expressRust ahead
Zero-copy view tied to its buffercompile timecan't expressRust ahead

The two Rust-ahead rows are the same trade: no lifetimes, so a view can't be tied to the buffer it points into — own the buffer and carry an offset instead. That cost lands on one shape only — an acyclic borrow into stable storage, which is exactly what 'a exists for. It does not land on cyclic data: a lifetime cannot describe a cycle either, so Rust reaches for the same generational handles there, which is why that row is even. See Ownership and Patterns Without Lifetimes for what to write instead, and Why There Are No Lifetimes for how much code the bet actually touches.

Second-class references — you cannot store one in a struct or a collection, and the only thing you can return is a view of a receiver's own data — mean nothing in the heap is ever aliased, which keeps both the mental model and the compiler drastically simpler.

The Rust shape, and what to write instead

A lookup table: find the Rust construct you were reaching for, read across. The three string cases and the self-referential ones are worked through with running code in Patterns Without Lifetimes.

ProblemRustMilo
Zero-copy view inside a scope&s[6..11]s[6..11], a &string view, no allocation
Zero-copy view returned to a callerfn items(&self) -> &[T]same: a method may return a view of its receiver's own storage, and the receiver is frozen while the view lives
Recursive data (tree, AST)Box<Expr>Heap<Expr>, dereferenced with *l
Doubly-linked listRc<RefCell<Node>> or unsafearena + Option<Handle<Node>>, linkedList.milo
Cyclic graph, cross-referencespetgraph, arena + indices, or RcArena<Node> + Vec<Handle<Node>> for edges, depgraph.milo
Tree with parent pointers (DOM)Rc<RefCell> or an arena crateArena<Node>, parent and children as handles, domArena.milo
Long-lived state across tasksArc<Mutex<T>>one owner holds the Arena<T> and passes handles; a module-scope var pool: Arena<Node> = Arena<Node>.new() works
Shared mutable state between workersArc<Mutex<T>>one task owns it, the others send to it over a Channel<T>
Shared immutable data between workersArc<[u8]>std/seal: seal then share, cloned per reader, no copy
Parallel map over one arrayrayon par_iter_mutstd/shard: parallelMap(v, n, f), or parallelMapWith for a worker pool and per-worker state
Spawn and jointhread::spawn + handle.join()Task.spawn + Task.join, or a WaitGroup for a fleet
Wait on first of several sourcestokio::select!std/select
Cursor or iterator holding a borrowstruct Cur<'a> { buf: &'a [u8] }own the buffer, carry an integer pos, slice on demand
Struct that stores a borrowstruct Parser<'a> { src: &'a str }no equivalent. Three answers, worked through

What large Rust codebases actually do

The rows above are a claim about a language. The more interesting question is what people write when they have lifetimes available and a real system to ship. Two large Rust codebases, counted directly:

Bun src/Linux rust/
Rust LOC1,036,969152,140
Types carrying a lifetime param767119
Rc< / Arc< (+ kernel ARef<)159144 + 123
unsafe13,9153,453

(Counted 2026-07-30 with rg; Bun at 10ff028898, Linux at master. Reproduce: rg -o "^\s*(pub(\([^)]*\))? )?(struct|enum) \w+<'" -g '*.rs'.)

Two things stand out.

Lifetime-carrying types are rare, and thin where they exist. Both sit near 0.8 per thousand lines. Bun's AST — the hottest data structure in a bundler, the exact place 'a is supposed to pay off — is 20,153 lines with 8 lifetime-carrying types, because the AST is arena-allocated (bun_alloc::Arena, recycled per thread) and nodes refer to each other by index. That is the arena-plus-handle pattern Milo makes the default, hand-built in a language that offered the alternative.

Runtime-checked lookup is not a concession — it is kernel idiom. A file descriptor is an integer index into a per-process table, validated on every use. In the kernel's own Rust that is literally:

rust
// rust/kernel/fs/file.rs
pub fn fget(fd: u32) -> Result<ARef<LocalFile>, BadFdError>

A stale or bogus index returns an error, at runtime — which is what arena.get(h) returning Option<&T> on a dead handle does. There are 395 -> Option<...> returns across the kernel's Rust tree, plus its own XArray and IDR index-to-object maps. What kernels forbid is not the check but the unrecoverable failure: Rust-for-Linux bans panics, makes every allocation fallible, and requires the caller to handle every lookup. Rust's panicking Vec index is the thing that had to go; Milo's checked handle is the thing that stayed.

Where the evidence cuts the other way, it is worth saying plainly: the kernel carries roughly eleven times Bun's density of refcounted shared ownership (1.8 vs 0.15 per thousand lines), because kernel objects — files, inodes, devices — are refcounted by design in the C they mirror. Value semantics fights that model harder than lifetimes ever did. See Kernel feasibility for the honest gap list.