Drop Check (dropck)
Drop Check (dropck)
Level 3 — Ownership & Borrowing The specific borrow-checker sub-analysis that verifies data is still valid when a destructor (
Drop) runs.
1. Prerequisites
DropTrait — The destructor mechanism this analysis specifically governs.- Borrow Checker — The broader system dropck is a specialized part of.
- Lifetime (
'a) — What dropck ultimately reasons about.
2. Term Category
Compiler Sub-Analysis (the destructor safety net): Dropck ("drop check") is the part of the borrow checker specifically concerned with one question: when a value's Drop::drop runs, are all the references it might touch still guaranteed valid? Without this check, a generic type holding a borrowed reference could have its destructor run after the referenced data was already gone.
3. Explanation
(1) Design Motivation — "Why did we design this?"
Consider a struct struct Holder<'a> { data: &'a str } that implements Drop with logic that reads self.data inside drop(). The ordinary borrow checker already ensures the reference is valid everywhere it's used in your code — but a destructor is special: it runs implicitly, at the end of scope, potentially interleaved with the destructors of other values in a specific, compiler-determined order (generally reverse declaration order). Dropck exists to specifically verify that whatever a type's Drop implementation might touch (based on its generic parameters and lifetimes) is guaranteed to still be alive at the exact moment that destructor actually executes — closing a soundness hole that the "normal" borrow-checking rules alone wouldn't catch, since a drop() call is never written explicitly in your source code for the compiler to see and check like any other statement.
(2) Reality Metaphor
Imagine a stage show where performers must exit through matching doors in a strict, camera-verified order.
- Ordinary borrow checking verifies that during the show, no performer stands in a spot they're not allowed to be in while the curtain is up and the audience is watching (while your code explicitly runs).
- Dropck is a separate safety inspector who specifically checks: "when the stage crew silently strikes the set after the show ends (when
Drop::dropruns implicitly), will any prop a performer needs to physically touch during their exit still actually be standing there, or might it have already been hauled away by an earlier cleanup crew?" This exit-order safety check happens for a moment that's never explicitly scripted in the show itself, so it needs its own dedicated inspection pass.
(3) Rust Code Examples
Short Snippet (What Dropck Prevents)
struct PrintOnDrop<'a>(&'a str);
impl<'a> Drop for PrintOnDrop<'a> {
fn drop(&mut self) {
println!("Dropping with data: {}", self.0); // Touches the borrowed data!
}
}
fn main() {
let text = String::from("hello");
let holder = PrintOnDrop(&text);
// Dropck ensures `text` cannot be dropped before `holder`, since `holder`'s
// destructor reads `text`'s data. The compiler enforces `text` outlives `holder`.
drop(holder); // Prints "Dropping with data: hello" — `text` is still valid here.
println!("{text}"); // Still usable — dropck's ordering guarantee held.
}
Fuller Example (Why Generic Drop Impls Need Extra Care)
// This struct is generic over T, and its Drop impl might (or might not) touch `T`'s data.
struct Wrapper<T> { value: T }
impl<T> Drop for Wrapper<T> {
fn drop(&mut self) {
// Even if this body does nothing with `self.value` directly, dropck
// CONSERVATIVELY assumes it might (since T could itself have interesting
// Drop logic), and requires any borrowed data inside T to outlive Wrapper.
println!("Wrapper dropped");
}
}
fn main() {
let text = String::from("borrowed data");
let w = Wrapper { value: &text };
// Dropck ensures `text` outlives `w`, even though THIS PARTICULAR drop() body
// doesn't touch `self.value` — it can't tell that in general, so it's conservative.
drop(w);
println!("{text}");
}
4. Common Mistakes & Pitfalls
Mistake 1: Misunderstanding Drop Check Scoping and Lifecycle Rules
The mistake: Assuming Drop Check instances remain valid beyond their declaring scope block or across asynchronous boundaries without explicit lifetime tracking.
Why it's wrong: Rust strictly enforces lexical scope boundaries and non-lexical lifetimes (NLL) at compile time. Accessing dropped values or failing to handle variable drop order results in compiler errors such as E0597 or E0382.
Incorrect:
fn get_ref() -> &str {
let s = String::from("drop_check_data");
&s // ❌ Error E0106/E0515: returns a reference to data owned by the current function
}
Fix:
fn get_string() -> String {
let s = String::from("drop_check_data");
s // Ownership of the String is transferred directly to the caller
}
Mistake 2: Mutating Drop Check State Without Exclusive Ownership or mut Borrowing
The mistake: Attempting to mutate data associated with Drop Check through an immutable reference &T or without specifying mut in variable declarations.
Why it's wrong: Rust's aliasing XOR mutability rule (&T for shared immutable access, &mut T for exclusive mutable access) prohibits mutating state through shared references unless interior mutability patterns (e.g. RefCell, Mutex) are explicitly used.
Incorrect:
fn update_val(data: &i32) {
// *data += 1; // ❌ Error E0594: cannot assign to `*data`, which is behind a `&` reference
}
Fix:
fn update_val(data: &mut i32) {
*data += 1; // Correct: exclusive mutable reference permits mutation
}
Mistake 3: Concurrent Access to Drop Check Across Threads Without Send / Sync Guards
The mistake: Sharing non-thread-safe Drop Check instances across OS threads via std::thread::spawn.
Why it's wrong: Types that do not implement Send or Sync marker traits cannot safely cross thread boundaries. The compiler prevents data races by raising compile errors E0277 (trait Send is not implemented).
Incorrect:
use std::rc::Rc;
use std::thread;
let rc = Rc::new(42);
// thread::spawn(move || { println!("{}", rc); }); // ❌ Error E0277: `Rc` cannot be sent between threads safely
Fix:
use std::sync::Arc;
use std::thread;
let arc = Arc::new(42);
thread::spawn(move || {
println!("{}", arc); // Correct: `Arc` implements `Send` and `Sync`
});
5. Practice Exercises
Exercise 1: Soundness in Custom Smart Pointers — PhantomData<T> and Drop Check Rules
Scenario: You are implementing a custom arena node smart pointer ArenaNodeGuard<'a, T> that manages dynamic heap allocations allocated by an arena. Because raw pointers (*mut T) do not carry dropck lifetime information, the compiler cannot automatically determine whether ArenaNodeGuard drops or accesses T when ArenaNodeGuard is dropped. Without proper markers, this can create dropck soundness holes where T holds references that expire before ArenaNodeGuard's destructor executes.
Requirements:
- Implement
ArenaNodeGuard<'a, T>holding a raw pointer*mut T, an atomic drop counter reference&'a AtomicUsize, andPhantomData<T>to instruct dropck thatArenaNodeGuardstrictly owns and dropsT. - Implement
DropforArenaNodeGuard<'a, T>, ensuringstd::ptr::drop_in_placeis invoked on the innerTbefore deallocating memory, followed by incrementing the drop counter. - Write unit tests proving that nested destructors execute in exact sequence (inner payload drop before arena guard drop) and verify drop order assertions using
assert_eq!,assert!,assert_ne!, andmatches!.
Answer
Implementation
use std::marker::PhantomData;
use std::sync::atomic::{AtomicUsize, Ordering};
/// Custom arena smart pointer holding raw memory and dropck markers.
pub struct ArenaNodeGuard<'a, T: 'a> {
ptr: *mut T,
drop_counter: &'a AtomicUsize,
_marker: PhantomData<T>, // Signals dropck that ArenaNodeGuard owns & drops T
}
impl<'a, T: 'a> ArenaNodeGuard<'a, T> {
pub fn new(value: T, drop_counter: &'a AtomicUsize) -> Self {
let boxed = Box::new(value);
let ptr = Box::into_raw(boxed);
Self {
ptr,
drop_counter,
_marker: PhantomData,
}
}
pub fn get(&self) -> &T {
// SAFETY: ptr is valid for 'a while ArenaNodeGuard is alive
unsafe { &*self.ptr }
}
pub fn get_mut(&mut self) -> &mut T {
// SAFETY: Exclusive borrow guarantees safe unique mutable access
unsafe { &mut *self.ptr }
}
}
impl<'a, T: 'a> Drop for ArenaNodeGuard<'a, T> {
fn drop(&mut self) {
unsafe {
// 1. Drop the inner T first before invalidating pointer address
std::ptr::drop_in_place(self.ptr);
// 2. Reclaim heap allocation
let _ = Box::from_raw(self.ptr);
}
// 3. Register guard drop completion
self.drop_counter.fetch_add(1, Ordering::SeqCst);
}
}
#[cfg(test)]
mod tests {
use super::*;
struct PayloadDropper<'a>(&'a AtomicUsize);
impl<'a> Drop for PayloadDropper<'a> {
fn drop(&mut self) {
self.0.fetch_add(10, Ordering::SeqCst);
}
}
#[test]
fn test_arena_guard_dropck_sequence() {
let guard_drop_counter = AtomicUsize::new(0);
let payload_drop_counter = AtomicUsize::new(0);
{
let guard = ArenaNodeGuard::new(
PayloadDropper(&payload_drop_counter),
&guard_drop_counter,
);
assert_eq!(guard_drop_counter.load(Ordering::SeqCst), 0);
assert_eq!(payload_drop_counter.load(Ordering::SeqCst), 0);
assert_eq!(guard.get().0.load(Ordering::SeqCst), 0);
}
// Verification: Inner payload dropped first (+10), then arena guard completes (+1)
assert_eq!(payload_drop_counter.load(Ordering::SeqCst), 10);
assert_eq!(guard_drop_counter.load(Ordering::SeqCst), 1);
assert_ne!(guard_drop_counter.load(Ordering::SeqCst), payload_drop_counter.load(Ordering::SeqCst));
assert!(guard_drop_counter.load(Ordering::SeqCst) > 0);
}
#[test]
fn test_arena_guard_mutability() {
let counter = AtomicUsize::new(0);
let mut guard = ArenaNodeGuard::new(42, &counter);
*guard.get_mut() += 8;
assert_eq!(*guard.get(), 50);
drop(guard);
assert_eq!(counter.load(Ordering::SeqCst), 1);
}
}
Technical Explanation
- Dropck & Raw Pointer Opaque Semantics: Raw pointers (
*mut T) are primitive types that do not implementDropand carry no ownership semantics. When compiler dropck analyzes a struct containing*mut T, it conservatively assumes the struct does NOT ownTand will NOT runT's destructor. IfArenaNodeGuardheld a borrowed reference insideT(e.g.T = &'b Str), dropck would permit'bto terminate beforeArenaNodeGuardis dropped, causing potential dangling reference access insideDrop::drop. PhantomData<T>Soundness Guard: AddingPhantomData<T>explicitly informs dropck thatArenaNodeGuard<'a, T>owns an instance ofTand will invokeT's destructor duringDrop::drop. This forces dropck to enforce the invariant: any lifetime parameter insideTmust strictly outlive theArenaNodeGuardinstance.- Destructor Execution Order: Inside
Drop::drop, callingstd::ptr::drop_in_place(self.ptr)ensuresT's destructor executes whileself.ptrstill points to valid allocated memory. Subsequently,Box::from_raw(self.ptr)deallocates the underlying heap buffer without callingT's destructor a second time (preventing double-free bugs).
Exercise 2: FFI Transaction Guards & Struct Field Drop Order Invariants
Scenario: In high-reliability database engines, transaction guards (TransactionGuard<'a>) borrow a connection handle (&'a DbConnection). If a transaction guard goes out of scope without an explicit call to commit(), its Drop implementation must trigger an automatic rollback and record an audit entry back into DbConnection. Drop check enforces that DbConnection cannot be destroyed while TransactionGuard is still active in a scope or stored in a parent container struct.
Requirements:
- Implement
DbConnectionmaintaining an audit log (RefCell<Vec<String>>) and connection ID. - Implement
TransactionGuard<'a>with state tracking (TxStatus::Active,Committed,RolledBack). - Implement
DropforTransactionGuard<'a>such that uncommitted transactions are automatically marked asRolledBackand log rollback actions toDbConnection. - Create comprehensive unit tests verifying automatic rollback behavior, explicit commit behavior, and drop sequence assertions using
assert_eq!,assert!,assert_ne!, andmatches!.
Answer
Implementation
use std::cell::RefCell;
#[derive(Debug, PartialEq, Eq, Clone, Copy)]
pub enum TxStatus {
Active,
Committed,
RolledBack,
}
pub struct DbConnection {
pub id: usize,
pub log: RefCell<Vec<String>>,
}
impl DbConnection {
pub fn new(id: usize) -> Self {
Self {
id,
log: RefCell::new(Vec::new()),
}
}
}
pub struct TransactionGuard<'a> {
conn: &'a DbConnection,
pub status: TxStatus,
operations: Vec<String>,
}
impl<'a> TransactionGuard<'a> {
pub fn new(conn: &'a DbConnection) -> Self {
conn.log.borrow_mut().push(format!("Tx started on conn {}", conn.id));
Self {
conn,
status: TxStatus::Active,
operations: Vec::new(),
}
}
pub fn record_op(&mut self, op: &str) {
self.operations.push(op.to_string());
}
pub fn commit(mut self) {
self.status = TxStatus::Committed;
self.conn
.log
.borrow_mut()
.push(format!("Tx committed with ops: {:?}", self.operations));
}
}
impl<'a> Drop for TransactionGuard<'a> {
fn drop(&mut self) {
if self.status == TxStatus::Active {
self.status = TxStatus::RolledBack;
self.conn
.log
.borrow_mut()
.push(format!("Tx auto-rolled back with ops: {:?}", self.operations));
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_transaction_guard_auto_rollback_on_drop() {
let conn = DbConnection::new(101);
{
let mut tx = TransactionGuard::new(&conn);
tx.record_op("INSERT INTO users VALUES ('Alice')");
assert_eq!(tx.status, TxStatus::Active);
// tx dropped implicitly at scope boundary
}
let logs = conn.log.borrow();
assert_eq!(logs.len(), 2);
assert_eq!(logs[0], "Tx started on conn 101");
assert!(logs[1].contains("Tx auto-rolled back"));
assert!(logs[1].contains("INSERT INTO users VALUES ('Alice')"));
}
#[test]
fn test_transaction_guard_explicit_commit() {
let conn = DbConnection::new(102);
{
let mut tx = TransactionGuard::new(&conn);
tx.record_op("UPDATE accounts SET balance = 500");
tx.commit(); // Consumes tx, status set to Committed
}
let logs = conn.log.borrow();
assert_eq!(logs.len(), 2);
assert_eq!(logs[0], "Tx started on conn 102");
assert!(logs[1].contains("Tx committed with ops"));
assert_ne!(logs[1], "Tx auto-rolled back");
}
#[test]
fn test_struct_field_drop_order_safety() {
// Struct fields are dropped in top-to-bottom declaration order
struct CompoundService<'a> {
guard: TransactionGuard<'a>, // Dropped FIRST
conn: &'a DbConnection, // Dropped SECOND (valid during guard drop!)
}
let conn = DbConnection::new(200);
{
let tx = TransactionGuard::new(&conn);
let _svc = CompoundService { guard: tx, conn: &conn };
} // CompoundService drops guard first -> guard accesses conn -> safe!
let logs = conn.log.borrow();
assert_eq!(logs.len(), 2);
assert!(matches!(logs.get(1), Some(entry) if entry.contains("auto-rolled back")));
}
}
Technical Explanation
- Dropck Scope Hierarchy: Dropck enforces that any reference borrowed by a struct (
&'a DbConnectioninsideTransactionGuard<'a>) must remain valid for the entire scope in whichTransactionGuard's destructor can potentially run. Attempting to dropDbConnectionbeforeTransactionGuardcauses compile-time borrow check failureE0597(borrowed value does not live long enough). - Scope LIFO Stack vs Struct Field Drop Order:
- Local variables in a block are dropped in strict LIFO (Last-In, First-Out) reverse declaration order.
let conndeclared beforelet txensurestxis dropped first. - Struct fields are dropped in top-to-bottom declaration order. In
CompoundService, declaringguard: TransactionGuard<'a>beforeconn: &'a DbConnectionguarantees thatguard's destructor runs while theconnreference field is still intact.
- Local variables in a block are dropped in strict LIFO (Last-In, First-Out) reverse declaration order.
- Interior Mutability in Destructors: Because
Drop::dropreceives&mut self, accessing shared state onDbConnectionrequires interior mutability (RefCellorMutex). Dropck permits callingborrow_mut()insidedrop()because lifetime'aguaranteesDbConnectionis still allocated and aliasing rules prevent concurrent mutable borrows across threads.
Exercise 3: Scoped Deferred Cleanup Pool with Generic Lifetime Bounds
Scenario: In high-throughput resource management (such as connection pools or graphics render context pipelines), allocating and destroying complex objects frequently incurs significant overhead. A ScopedResourcePool<T> yields scoped handles (ResourceHandle<'a, T>). When a handle is dropped, its Drop implementation extracts the inner payload T and moves it into the pool's deferred cleanup log (cleanup_log) while decrementing the active allocation counter. Dropck must conservatively verify that both the parent pool ScopedResourcePool<T> and the generic payload T outlive ResourceHandle<'a, T>.
Requirements:
- Implement
ScopedResourcePool<T>holding an active allocation counterRc<RefCell<usize>>and a deferred cleanup queueRc<RefCell<Vec<T>>>. - Implement
ResourceHandle<'a, T>wrapping a reference toScopedResourcePool<T>and anOption<T>payload. - Implement
DropforResourceHandle<'a, T>to safely extract the payload usingOption::take()and append it tocleanup_log, updating active count. - Write unit tests testing multiple handle lifetimes, deferred cleanup logging, and drop pattern matches using
assert_eq!,assert!,assert_ne!, andmatches!.
Answer
Implementation
use std::cell::RefCell;
use std::rc::Rc;
/// Scoped resource pool tracking active allocations and deferred drop payloads.
pub struct ScopedResourcePool<T> {
pub active_count: Rc<RefCell<usize>>,
pub cleanup_log: Rc<RefCell<Vec<T>>>,
}
impl<T> ScopedResourcePool<T> {
pub fn new() -> Self {
Self {
active_count: Rc::new(RefCell::new(0)),
cleanup_log: Rc::new(RefCell::new(Vec::new())),
}
}
pub fn allocate<'a>(&'a self, payload: T) -> ResourceHandle<'a, T> {
*self.active_count.borrow_mut() += 1;
ResourceHandle {
pool: self,
payload: Some(payload),
}
}
}
pub struct ResourceHandle<'a, T> {
pool: &'a ScopedResourcePool<T>,
payload: Option<T>,
}
impl<'a, T> ResourceHandle<'a, T> {
pub fn payload(&self) -> Option<&T> {
self.payload.as_ref()
}
}
impl<'a, T> Drop for ResourceHandle<'a, T> {
fn drop(&mut self) {
// Safely extract payload without moving out of &mut self directly
if let Some(data) = self.payload.take() {
self.pool.cleanup_log.borrow_mut().push(data);
}
let mut count = self.pool.active_count.borrow_mut();
if *count > 0 {
*count -= 1;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_scoped_resource_pool_deferred_cleanup() {
let pool: ScopedResourcePool<String> = ScopedResourcePool::new();
{
let h1 = pool.allocate(String::from("Buffer_A"));
let h2 = pool.allocate(String::from("Buffer_B"));
assert_eq!(*pool.active_count.borrow(), 2);
assert_eq!(h1.payload(), Some(&String::from("Buffer_A")));
assert_eq!(h2.payload(), Some(&String::from("Buffer_B")));
drop(h1); // Explicit early drop of h1
assert_eq!(*pool.active_count.borrow(), 1);
assert_eq!(pool.cleanup_log.borrow().len(), 1);
assert_eq!(pool.cleanup_log.borrow()[0], "Buffer_A");
}
// Both handles dropped, all payloads transferred to pool cleanup queue
assert_eq!(*pool.active_count.borrow(), 0);
assert_eq!(pool.cleanup_log.borrow().len(), 2);
assert_eq!(pool.cleanup_log.borrow()[1], "Buffer_B");
let log = pool.cleanup_log.borrow();
assert!(matches!(log.first(), Some(s) if s == "Buffer_A"));
assert_ne!(log[0], log[1]);
}
#[test]
fn test_resource_handle_option_take() {
let pool: ScopedResourcePool<i32> = ScopedResourcePool::new();
let handle = pool.allocate(99);
assert_eq!(handle.payload(), Some(&99));
// Verify handle drop registers payload into cleanup_log safely
drop(handle);
assert_eq!(pool.cleanup_log.borrow()[0], 99);
}
}
Technical Explanation
- Dropck Generic Parameters Conservative Analysis: Dropck conservatively assumes that any generic parameter
Ton a type implementingDropmight be accessed during destructor execution. Even thoughResourceHandle<'a, T>storesOption<T>and moves it viaself.payload.take(), dropck requires thatT's lifetime (and all references insideT) must strictly outlive'aand the duration ofdrop(). - Option Take Pattern in Destructors: Rust prohibits moving values directly out of a type that implements
Drop(E0509: cannot move out of type which implements the Drop trait). Wrapping generic payloads inOption<T>allowsself.payload.take()to replace the field withNonewhile transferring ownership ofTinto the pool's deferred cleanup log without violating move constraints. - Lifetime Bound
'aVerification: The lifetime parameter'atiesResourceHandle<'a, T>to the borrowing scope of&'a ScopedResourcePool<T>. Dropck ensuresScopedResourcePool<T>cannot be moved or dropped while anyResourceHandle<'a, T>is live, guaranteeingself.pool.cleanup_log.borrow_mut()will never dereference a dangling pointer.
6. Related Terms
DropTrait — The destructor mechanism dropck specifically protects.- Borrow Checker — The broader compiler system dropck is a specialized extension of.
- Lifetime Variance — Closely intertwined with dropck's reasoning about generic lifetime parameters.
PhantomData<T>— Sometimes used specifically to communicate drop-related ownership semantics to dropck for types using raw pointers.
7. Key Takeaways
- Dropck is the borrow-checker sub-analysis ensuring data is still valid at the exact, implicit moment a value's destructor runs.
- It exists because
Drop::drop()calls are never written explicitly in your code, so ordinary borrow checking alone can't verify their safety. - It's deliberately conservative for generic types — it assumes a generic parameter's data might be touched during drop, even if a specific
drop()body doesn't touch it. - The unstable
#[may_dangle]attribute is the (unsafe, nightly-only) escape hatch for opting a specific parameter out of this conservative assumption.