Safety in an unsafe { world }

title
Safety in an unsafe { world }
type
summary
summary
joshlf's RustConf talk on teaching Rust safety properties the language cannot reason about
tags
rust, type-system, concurrency, api-design
created
2026-07-29
updated
2026-07-29

Joshua Liebow-Feeser's RustConf 2024 talk, written up nearly verbatim. The argument is that "buggy programs don't compile" is not a property Rust gives you β€” it is a property a library gives you, and Rust only ships one instance of it (memory and thread safety) in the box.

The evidence is Netstack3, Fuchsia's pure-Rust networking stack, meant to replace the Golang-written Netstack2. Six years, roughly ten developers, 63 crates, 192,000 lines β€” more code than the top ten crates on crates.io combined. Networking code is famously hard to test, so the expectation for a ground-up netstack rewrite is months or years of field dogfooding turning up tens to hundreds of bugs before real users see it. Netstack3 ran an 11-month dogfooding program, at peak about 60 devices running nearly 24/7 in developers' homes, and found three bugs. A note added after the talk reports it now runs on millions of devices, with roughly 20x fewer crashes per million devices per day and 50% less memory than Netstack2.

Memory safety alone cannot explain that. Implementing what RFC 4614 calls "basic functionality" for TCP means six standards over 270 pages; with the recommended enhancements it's 18 standards over 476 pages. That's one protocol out of Ethernet, ARP, NDP, IPv4, IPv6, ICMP, IGMP, MLD, UDP and the rest, plus their interactions. Memory and threading bugs are a small slice of what can go wrong.

The framework

The example is a binary tree whose ordering invariant Rust has no visibility into:

struct Node<T> {
    // INVARIANT: All values in `left` are less than `value`.
    left: Option<Box<Node<T>>>,
    // INVARIANT: All values in `right` are greater than `value`.
    right: Option<Box<Node<T>>>,
    value: T,
}

Three components. Definition: create an object the type system can reason about (Node) and attach to it a property the type system cannot (ordering), documented in prose because there is nowhere else to put it. Enforcement: make the fields private so outside code cannot break the property, and make every method that constructs or mutates a Node preserve it. Consumption: write code that is only correct because the property holds β€” here, finding a value's position in O(log N) instead of walking the whole tree.

The claim that makes this more than a documentation convention: from the perspective of safe code outside the module, there is no difference between this invariant and one the language enforces. Code that would violate the ordering does not compile, for the same reason code that would violate memory safety does not compile.

Send is a library, not a language feature

The first worked example is that Rust's advertised thread safety is not a language feature at all. std::thread::spawn has to call out to something like pthread_create, which lives outside the language, so it goes in an unsafe block β€” the programmer takes on the proof obligation Rust can't discharge. That's unsound on its own, because nothing stops the caller passing a closure capturing an Rc. So the standard library defines an unsafe marker trait whose meaning exists only in its doc comment:

/// # Safety
///
/// `Self` is thread-safe.
pub unsafe trait Send {}

Definition is the trait plus its prose. Enforcement is unsafe impl (you can only lie by typing unsafe), hand-written impls for primitives and for &Mutex<T>, and a derive that requires every field to be Send. Consumption is the F: Send bound on spawn, which is what justifies the pthread_create call. The real Send uses auto-trait machinery for ergonomics, but nothing about the design needed to live in the compiler β€” a third party could have written it. See rust-send-sync for what Send and Sync actually mean and how &T: Send relates to T: Sync; rust-async-trait-sync-bound is a case of those bounds propagating somewhere nobody asked for them.

memory-safety-absolutists applies this reading to the claim that unsafe disqualifies Rust from being called memory-safe. If unsafe is the tool for encapsulating a proof the compiler cannot check, then its presence says nothing on its own, and the question worth asking is how often the proofs turn out to be wrong.

Deadlock freedom as a type-system property

The second example is Alex Konradi's lock-ordering work in Netstack3. Fine-grained locking gives you a lock-order graph, and deadlock freedom means that graph is acyclic. Netstack3 has 77 mutexes across 192,000 lines, so tracking acquisition order by hand does not scale β€” and Netstack2, in Golang, had a history of deadlocks.

Each mutex gets a name as a phantom type parameter:

struct Mutex<Id, T> {
    mtx: std::sync::Mutex<T>,
    _marker: PhantomData<Id>,
}

enum IpLock {}
enum DeviceLock {}

The graph edges become unsafe traits LockAfter<M> and LockBefore<M>, generated by an impl_lock_after!(A => B) macro that also emits a blanket impl. That blanket impl is the trick: a cyclic invocation of the macro produces conflicting blanket implementations, so cyclic graphs fail to compile. Position in the graph is tracked by a zero-sized LockCtx<Id>, and lock threads it through:

pub fn lock<L>(&self, ctx: &mut LockCtx<L>) -> (MutexGuard<'_, T>, LockCtx<Id>)
where
    L: LockBefore<Id>,

The mutable borrow disables the old context for as long as the guard lives, so the returned LockCtx<Id> is the only usable one, and it only permits locking things downstream of Id. Locking device then IP, when the graph says IP comes first, is a trait-bound error at the second lock call rather than a hang in production.

The payoff was a one-line diff. After years of threading mutexes and guards through the whole stack, the team changed the default worker count from 1 to 4 β€” and it was bug-free on the first try.

Liebow-Feeser is candid that the presented version is simplified: he knows of two ways to circumvent deadlock safety in it, and the real library deliberately settles for "hard enough that you won't do it by accident" rather than literally impossible. That tradeoff is left open rather than resolved.

The advice

Treat partial functions as a code smell. Public functions shouldn't panic, and shouldn't return Option or Result unless failure is inherent to what they model β€” I/O failing is fine, rejecting an illegal argument means the call should not have compiled.

Make the API's shape match the problem's shape. Netstack3's IP parse error carries a parameter problem pointer β€” the byte offset of the malformed field, echoed back in the error packet. IPv4 stores that in one byte, IPv6 in four, so the field is generic over the IP version (I::ParameterProblemPointer) rather than uniformly u32. A u32 would push a fallible conversion somewhere else in the codebase, where a bug either emits a wrong error packet or panics the stack.

Apply the same rigor to internal APIs as to public ones β€” the obvious-in-the-moment calling convention won't be obvious to whoever arrives four years and six refactors later. And don't expect a recipe: the lock-ordering cycle detection is not something that falls out of the language naturally. Quoting Rob Pike on Golang's design, "simplicity is the art of hiding complexity" β€” the internals of the lock-ordering library are ugly, and the mental model its users need is one sentence: cyclic graphs don't compile.

The methodology has a blind spot worth naming, and tokio-progress-not-ordering is a good illustration of it: the invariant there (all tasks from one event finish near their submission time) is a property of the application, not of any type the runtime can see, so no amount of library discipline inside Tokio would have caught it.