The premise of Rust’s safety guarantees is seductive in its simplicity: if your code compiles without unsafe, you cannot produce undefined behavior. This holds in the narrow technical sense. But it has always obscured a subtler truth, one that Aria Beingessner’s talk at TokioConf 2026 puts directly: safe Rust and unsafe Rust are not isolated from each other. They share a contract, and that contract runs in both directions.
Understanding where that contract lives, and what happens when it frays, is not an academic exercise. It matters practically for anyone writing library code in the async Rust ecosystem.
The Trust Hierarchy
When you write unsafe in Rust, you are not flipping a switch that enables undefined behavior. You are accepting a set of obligations. The compiler relaxes certain checks, and in exchange you promise to uphold invariants that the type system cannot verify. The canonical list includes things like: valid pointer alignment, no aliased mutable references, initialized memory on read, and no data races.
The key point is that unsafe blocks are bounded. They are small regions of code where a human programmer is taking responsibility. Safe code outside those blocks trusts the unsafe code to have kept its end of the bargain. If it hasn’t, the safe code above it is not wrong, but the program’s behavior is still undefined.
This is the first edge: unsafe code that fails its obligations corrupts the foundation on which safe code stands.
But there is a second, less obvious edge. Safe code can violate the preconditions that unsafe code depends on, even without ever writing unsafe itself.
How Safe Code Breaks Unsafe Code
Consider Pin<P>. The Pin type was introduced specifically to support self-referential data structures in async Rust. Its core guarantee is that once a value is pinned, it will not be moved in memory for the rest of its lifetime. Future implementations depend on this. If you write an async function, the compiler generates a state machine struct whose fields may contain raw pointers back into sibling fields. If that struct moves, those pointers become dangling.
The Pin API is carefully designed so that constructing a Pin<&mut T> from safe code requires T: Unpin, unless you go through unsafe { Pin::new_unchecked(...) }. So far, so good.
But consider what happens when you implement Drop on a type that contains a Pin. The destructor can call methods that expect pinned access to self, but by the time drop runs, the object is being dismantled. If you implement DerefMut incorrectly on a smart pointer wrapping a pinned type, you can produce a mutable reference to a pinned value, which allows moving it. That is a soundness hole, and it is reachable through entirely safe code on the caller’s side.
The Tokio ecosystem discovered several variations of this during the development of its tokio::sync primitives. A Mutex<T> holding a pinned future, accessed through a guard that implements DerefMut, can be moved out of the guard in safe code. The unsafe is buried in the smart pointer implementation, but the observable misbehavior is triggered by a pattern that looks completely innocuous.
The Send and Sync Problem
The async executor model makes the safe/unsafe boundary especially sharp around Send and Sync. These marker traits are the mechanism by which Rust enforces thread safety. They are auto-implemented by the compiler based on the types a struct contains. And they are unsafe to implement manually, because implementing them is a promise to the compiler that your type can be safely shared or transferred across threads.
The problem is that Send and Sync interact with the executor in ways that create non-obvious requirements. Tokio’s spawn function requires the future to be Send, because the executor may move the future between threads between await points. If your future captures a Rc<T> or a raw pointer, it is not Send, and the compiler catches this.
But if you wrap the raw pointer in a newtype and implement Send on that newtype because you believe your usage is thread-safe, you have made a claim the compiler cannot verify. If that belief turns out to be wrong, data races become possible, and since data races are undefined behavior in Rust’s memory model, all bets are off. The executor’s safe scheduling code is innocent. The spawning call site is innocent. The unsoundness lives entirely in that unsafe impl Send.
The Miri interpreter can catch some of these at test time by detecting invalid memory accesses and data races under a strict interpreter that enforces the abstract machine semantics. But Miri cannot run all programs, and it cannot run against a real async executor in any practical way today.
The Stacked Borrows Model and Async
Rust’s experimental Stacked Borrows model, developed by Ralf Jung and collaborators, is the most formal attempt to define what the aliasing rules actually are. It provides a way to reason about whether a sequence of memory operations is valid according to the rules the optimizer is permitted to assume.
Async Rust stress-tests Stacked Borrows in specific ways. When a future is polled, the executor calls poll with a Pin<&mut Self> and a Context. Between polls, the future sits in memory, potentially on the heap inside a Box, and is not touched. The executor may have held references to fields of the future during one poll that it no longer holds between polls. Under Stacked Borrows, the validity of future memory accesses depends on the order in which borrows are created and released, and the state machine transformations that the compiler applies to async functions can produce access patterns that are difficult to reason about manually.
Some of the intricate work that went into async-std and Tokio’s internal primitives involved careful analysis of whether the generated state machines actually respected the aliasing discipline the optimizer expected. Getting this wrong silently was possible; getting it detectably wrong required running under Miri with experimental flags enabled.
Safe Abstractions and the Public API Problem
Library authors face a specific version of this problem. You can write a perfectly sound unsafe implementation, but if your public API exposes enough surface area, a determined user can combine safe operations in a way that violates your invariants.
This is sometimes called the “leakpocalypse” problem, a name from a 2015 discussion in the Rust community. The original form of it: can a safe user of your API cause memory to be leaked? Yes, using std::mem::forget or reference cycles. This was a breaking change to certain safe abstractions that assumed destructors would always run.
The lesson generalized: any invariant your unsafe code depends on, that safe code can violate, is a soundness hole. The classic cases are:
- Drop not running:
mem::forget, reference cycles, orManuallyDropcan all prevent destructors from running. If yourunsafecode relies on the destructor to release a lock or zero sensitive memory, that guarantee does not hold. - Moved-out values: If your
unsafecode takes a reference to a struct field and stores it as a raw pointer, you need to ensure the struct cannot be moved after that point. The type system only enforces this viaPin, andPinonly prevents moves in safe code if the type is!Unpin. - Reentrance: If your type’s methods are called recursively, through callbacks or closures, the internal state may be in a partially modified state that your
unsafecode assumed would never be observed.
Each of these is a case where safe code is not doing anything wrong by its own rules, but unsafe code underneath has implicitly assumed that safe code would stay within narrower bounds.
What the Async Ecosystem Has Learned
Tokio has been one of the most rigorous codebases in the Rust ecosystem for working through these issues. The development of primitives like tokio::sync::Mutex, watch, and mpsc involved multiple rounds of soundness review, and in several cases, initially shipped versions were later found to have edge-case unsoundness and were fixed in subsequent releases.
A few patterns have emerged as load-bearing:
Minimize the unsafe surface. The less code that lives inside unsafe blocks, and the narrower the invariants those blocks depend on, the easier it is to audit. Tokio’s approach is to push as much logic as possible into safe code and write thin unsafe wrappers around the irreducible primitives, like atomic operations and UnsafeCell accesses.
Document preconditions explicitly. Every unsafe fn and every unsafe block that depends on external invariants should have a comment explaining exactly what those invariants are and why they hold at that point. This is enforced by Clippy’s undocumented_unsafe_blocks lint, which is now stable.
Test with Miri. Even if Miri cannot run your entire test suite due to FFI or OS interaction, it can run the pure logic paths. A failure under Miri is a concrete unsoundness. Running your core data structure tests under Miri catches a class of bugs that no number of address-sanitized tests will find, because Miri operates at the level of the abstract machine, not the hardware.
Audit Send and Sync impls separately. Treat every manual unsafe impl Send and unsafe impl Sync as a claim requiring justification. The justification should be written in a comment and should be reviewed independently from the rest of the implementation.
The Broader Point
Safe Rust is not a sandbox. It is a set of type-system-enforced contracts that makes it very hard to write undefined behavior by accident. The unsafe boundary is the place where those contracts become the programmer’s explicit responsibility rather than the compiler’s implicit enforcement.
What the async ecosystem has clarified is that the boundary runs in both directions. unsafe code can fail to uphold its obligations to safe code above it, and safe code can fail to meet the preconditions that unsafe code below it silently depends on. Both failure modes produce unsoundness. Only the first one has a Rust keyword attached to it.
The tools for navigating this are better than they were five years ago. Miri is more capable. The Stacked Borrows formalism gives a vocabulary for precise aliasing arguments. The unsafe code guidelines working group has produced substantial documentation about what the rules actually are. Libraries like loom let you test concurrent data structures under a model checker that explores thread interleavings exhaustively.
But the fundamental discipline remains the same as it has always been: understand precisely what each unsafe block is promising, and verify that every caller, safe or otherwise, actually keeps those promises. The type system helps, but it is not sufficient on its own, and recognizing that limitation is the starting point for writing genuinely sound Rust.