Skip to content

The Rust Engineering Handbook / Chapter 71

Unsafe Traits, Send/Sync, Drop Safety, and Panic Safety

Compose unsafe trait, thread-safety, destruction, unwinding, and reentrancy obligations into one reviewable proof.

An unsafe impl Send cannot be reviewed one line at a time. The line may be locally plausible—its raw pointer denotes an allocation and the wrapper appears to own that allocation—but moving the wrapper changes which thread may run every method and its destructor. A panic can interrupt those methods. A destructor can invoke user code. A callback can reenter while an invariant is temporarily false. A later field can silently change the wrapper’s ownership or synchronization behavior without changing the impl.

The useful review question is therefore not “is this pointer sendable?” It is: under every reachable method, unwind, drop, and callback path, does transferring this value preserve the trait contract and the representation invariant? Unsafe traits compose promises. The proof has to compose too.

This is the next step after proving validity, provenance, aliasing, and encapsulation. Those local facts remain necessary, but they are not sufficient once unchecked code participates in generic dispatch, cross-thread transfer, destruction, or control flow that the callee does not own.

Unsafe traits move obligations into implementations

Declaring a trait unsafe means incorrect implementations may cause undefined behavior in code that relies on the trait. It does not mean every method is unsafe to call, nor does it make the implementation body privileged. A safe generic function may use the trait’s promise without rechecking each implementer. That is why writing the implementation requires an unsafe impl: the author is accepting obligations that the compiler cannot verify.

Consider a scheduler abstraction that may transfer jobs to another thread:

pub unsafe trait TransferableJob {
    /// After transfer, invoking `run` on the destination thread is valid.
    fn run(self: Box<Self>);
}

The trait needs a precise safety contract. Does a job permit thread-affine handles? May it contain pointers into a source-thread stack? May run call a runtime that requires registration? Is destruction on the destination thread allowed if the job is cancelled before run? The last question is easy to miss. Transfer moves not merely method execution but also the possible location of Drop.

An unsafe trait should be rare. If all invalid implementations merely produce wrong output, deadlock, or an ordinary panic, a safe trait with documented semantic laws is normally enough. Unsafety is justified when generic code must perform operations whose memory safety depends on implementation facts it cannot inspect. The contract must identify those operations and facts rather than saying only “implementors must be thread safe.”

Review the trait and every unsafe implementation as a paired API:

  • which safe consumer relies on the promise;
  • which facts the implementer must establish;
  • whether those facts remain true for all generic parameters and lifetimes;
  • whether safe methods can invalidate them;
  • what happens if a method panics or is cancelled;
  • where and when destruction may occur;
  • whether callbacks or destructors can reenter;
  • which tests exercise behavior and which premises still require reasoning.

Adding a required method or weakening a bound can change the proof even if downstream code still compiles. Unsafe trait evolution is therefore both an API-compatibility concern and a safety-case change.

Send and Sync are structural promises

Send means a value of the type may be transferred to another thread. Sync means shared references to the type may be used from multiple threads; equivalently, T is Sync when &T is Send. They are unsafe auto traits: the compiler derives them structurally when all relevant fields permit it, and safe generic code relies on the result.

The traits answer different questions. An exclusively owned T may be movable even when shared access would not be safe. Cell<u32>, for example, can move between threads but is not Sync, because its shared methods can mutate without synchronization. Conversely, a wrapper that offers only synchronized shared access may be Sync when its internal types and protocol justify that conclusion.

Auto-trait derivation is usually the best proof. It stays aligned with field changes. Manual positive implementations should be a visible exception, typically needed because raw pointers do not convey ownership to the compiler or because the representation uses a lower-level primitive behind a stronger API.

The lab’s RawOwner<T> owns a T through NonNull<T>:

pub struct RawOwner<T> {
    ptr: NonNull<T>,
    _owns: PhantomData<T>,
}

unsafe impl<T: Send> Send for RawOwner<T> {}
unsafe impl<T: Sync> Sync for RawOwner<T> {}

These bounds are not decoration. RawOwner<Rc<_>> must not become Send, and shared access to RawOwner<Cell<_>> must not become Sync. The API exposes &T from &self, &mut T only from &mut self, never duplicates ownership, and reconstructs exactly one Box<T> during drop. Under those premises, moving the owner has the same thread-safety requirement as moving T, and sharing it has the same requirement as sharing T.

An unconditional implementation would be unsound:

// Unsound: erases T's thread-safety requirements.
unsafe impl<T> Send for RawOwner<T> {}

It would let safe code send thread-affine or non-atomic reference-counted state across threads. The raw pointer is not the decisive field; the logically owned T is. PhantomData<T> records that ownership relationship for variance, drop checking, and auto-trait analysis. A marker is part of the proof model, not a zero-cost charm to satisfy the compiler.

Negative auto-trait intent also matters. A handle tied to a GUI thread, event loop, or foreign runtime often must remain !Send or !Sync even if its current fields happen to derive the trait. On stable Rust, designs commonly include a marker whose structural properties prevent the unwanted auto trait, or arrange ownership around a genuinely thread-bound field. Review that marker as an explicit policy and test it with compile-fail evidence where practical. Do not rely on an incidental private field remaining non-sendable forever.

A composed unsafe-obligation map starts with RawOwner of T and conditional manual Send and Sync implementations. It traces a normal-to-mutating-to-committed state transition, an unwind guard that restores or poisons state, exactly-once destruction, and a callback state whose nested entry is blocked.
A manual auto-trait proof reaches beyond the impl line. It must survive every mutation, unwind, callback, and destruction path that transfer or sharing makes reachable.

The visual is a dependency map, not a sequence every type must implement. A type with no callback has no reentrancy branch. A type with purely atomic state may not need a mutation guard. The review obligation is to include every branch the actual abstraction exposes.

Ownership markers, variance, and drop checking belong in the same review

A raw pointer field does not tell Rust whether a wrapper owns, borrows, or merely observes the pointee. Those meanings affect auto traits, variance, and whether referenced data must still be alive when the wrapper is dropped. Marker fields communicate part of that relationship to the type system.

For a logically owning wrapper, PhantomData<T> says that the wrapper behaves as though it contains a T. That influences auto traits and tells drop checking that destruction may touch a T. A borrowed raw view more often needs a lifetime-bearing marker such as PhantomData<&'a T> or PhantomData<&'a mut T>, chosen to match its actual aliasing authority. A marker that claims shared borrowing while methods mutate through the pointer is a lie even if it makes desired variance fall out.

Variance determines which lifetime substitutions are allowed. Covariance can shorten a shared borrow; invariance blocks substitution. Mutable access and callbacks that accept borrowed values often require more conservative relationships than a read-only view. Do not choose a marker by copying another crate’s syntax. Write the semantic relationship first:

  1. Does the wrapper own the pointee, borrow it, or only retain an address token?
  2. Can it produce shared references, mutable references, or neither?
  3. Can it outlive or be substituted over the pointee lifetime?
  4. Does drop read, mutate, or destroy the pointee?
  5. Which Send/Sync behavior should follow from the logical relationship?

Then verify that fields and markers encode those answers. A compile-time variance probe can demonstrate accepted substitutions, but the test does not decide which substitutions are sound. The API’s aliasing and destruction model does.

Drop checking is deliberately conservative because a destructor may observe fields and borrowed data at the end of a value’s lifetime. A type that merely stores a raw pointer can otherwise look disconnected from the data its destructor accesses. If the destructor dereferences a borrowed pointer, the type must carry an appropriate lifetime relationship so safe construction cannot let the pointee die first.

Advanced escape hatches that relax drop checking require a proof about what the destructor does not access and remain unsuitable as a reflexive response to a rejected design. Prefer representation and API changes that make the actual lifetime visible. If a sound implementation depends on unstable language features, isolate it, state the toolchain policy, and provide a stable design for the core handbook path.

Destruction is an operation on the invariant

Drop runs on normal scope exit, during stack unwinding, when a container removes an element, and potentially on a different thread after ownership transfer. It must be included in the operation ledger just like push, remove, or poll.

For RawOwner<T>, the destructor proof is short but exact:

impl<T> Drop for RawOwner<T> {
    fn drop(&mut self) {
        // ptr came from one Box::leak; ownership was never duplicated or exported.
        unsafe { drop(Box::from_raw(self.ptr.as_ptr())) }
    }
}

The safety comment depends on the entire API. If a new into_raw(&self) returns the pointer as if it transferred ownership, the comment becomes false. If Clone duplicates the pointer, both owners reconstruct a box. If a method replaces the pointer without destroying or transferring the old allocation, it leaks. If a method can leave the pointer dangling before panicking, unwinding reaches a destructor that frees invalid storage.

The best representation makes partial states explicit. Use Option<NonNull<T>>, an enum, or ManuallyDrop<T> with a carefully documented state transition when ownership may be taken before the wrapper dies. Move the state to “empty” before an operation that can panic after transfer. The destructor can then branch on an ordinary valid state rather than infer ownership from several flags.

Destructor invariants have two layers:

  • entry validity: every state from which Drop can run is safe to inspect;
  • cleanup completeness: each live resource is destroyed or transferred exactly once, in an order that does not use already-destroyed state.

The destructor should normally avoid panicking. A panic while another panic is unwinding aborts the process. Even outside unwinding, panicking destructors make cleanup order and operational recovery difficult to reason about. Treat fallible shutdown as an explicit method returning Result; let Drop perform a best-effort, non-panicking fallback. Document errors that can be lost, and instrument explicit shutdown so operators can distinguish it from fallback cleanup.

Foreign resources add thread and ordering constraints. A handle may need to be released by the creating thread, before a runtime shuts down, or through a matching allocator. If a value is Send, its destructor may execute on the destination thread. An unsafe Send proof that checks method calls but ignores release affinity is incomplete.

Panic safety is about reachable states, not catching every panic

Rust unwinding can stop a safe or unsafe method at any call that panics: allocation, indexing, formatting, user comparison, cloning, a callback, or a destructor for a temporary. Unsafe code may temporarily violate its abstraction invariant while moving bytes or changing ownership. If unwinding exposes that temporary state to safe code or to Drop, later safe operations can trigger undefined behavior.

Classify an operation’s panic guarantee:

  • strong: failure leaves the observable value unchanged;
  • basic: failure may change the value, but it remains valid, destructible, and within its documented invariants;
  • poisoning/disabled: failure marks the value unusable until explicit recovery or destruction;
  • abort-only: the component relies on aborting panic behavior and does not support recovery.

These are design choices, not language keywords. State the chosen guarantee for invariant-sensitive operations. The basic guarantee is often sufficient, but “valid” must include raw-pointer, initialization, ownership, and destructor facts.

An insertion into an uninitialized slot illustrates ordering. A robust sequence might reserve a slot, write the element, and only then increment the initialized length. If element construction occurs before the method receives ownership, that construction cannot leave the container half-updated. If metadata must change first, install a guard that restores metadata or completes cleanup during unwinding.

RAII guards are valuable because their cleanup runs on normal return and unwind. A guard must itself have a non-panicking, valid destructor. It should record the smallest rollback state, avoid calling arbitrary user code where possible, and be forgotten or marked committed only after the invariant holds. A guard that borrows the whole container can also prevent accidental access to the temporarily invalid state in safe code.

catch_unwind is not a general soundness patch. It catches only unwinding panics, not aborts; payloads and destructors can create further complications; and it does not retroactively repair an invariant. Use it at explicit containment boundaries after internal operations already provide a valid unwind state. Common boundaries include plugin hosts, thread pools that isolate jobs, and C ABI entry points. The contained component still needs a recovery policy.

Poisoning is one such policy. A lock may record that a panic occurred while protected data could be inconsistent. Internal poisoning can prevent further safe methods from trusting a partially restored state. But poisoning is not proof that memory safety survived; the guard must first leave storage valid and destructible. Poisoning then communicates that application-level correctness needs review or reconstruction.

Reentrancy creates concurrency without another thread

Calling user code while an invariant is open is dangerous even in a single-threaded program. A comparator, allocator hook, logger, destructor, or foreign callback may call back into the same object. The second call observes a state the public API normally promises is impossible.

Suppose a registry marks an entry “being removed,” invokes a callback, and then unlinks it. If the callback calls remove again, the implementation may unlink twice, destroy the same allocation twice, or invalidate an iterator held by the outer call. A mutex does not automatically solve this: a non-reentrant mutex deadlocks, while a reentrant mutex permits the invariant violation.

Choose an explicit callback policy:

  1. No reentrancy: set an in_callback state before invoking user code; reject or fail closed on nested entry; reset it with an RAII guard on return or unwind.
  2. Deferred mutation: callbacks enqueue commands that run after the outer operation restores its invariant.
  3. Snapshot: invoke callbacks using copied or reference-counted immutable data detached from the live mutation.
  4. Designed reentrancy: define state transitions that make nested calls valid, usually at substantial complexity cost.

The first policy appears in the lab because it is easy to audit, not because it is universal. A boolean guard is sufficient only when access is serialized and the callback runs synchronously. Cross-thread callbacks need synchronization; asynchronous callbacks need owned state and a lifetime protocol. If the callback can long-jump, throw a foreign exception, or never return, Rust RAII assumptions may not hold at that foreign boundary.

Do not hold raw borrows across an opaque callback unless the callback contract rules out operations that invalidate them. It is often safer to copy the value needed by the callback, restore the main invariant, release internal locks, and then call outward. That ordering trades freshness or allocation for a much smaller proof.

A combined review of RawOwner<T>

Review the lab type by following dependencies rather than declarations.

Representation. ptr is non-null, aligned, and points to one initialized T allocated by Box. The owner has exclusive logical ownership. PhantomData<T> represents owned T for auto traits and destruction.

Construction. Box::new creates the allocation; Box::leak suppresses automatic destruction while preserving a pointer. No panic after leaking can lose the pointer before it is stored because the remaining struct construction is non-panicking.

Shared access. get(&self) -> &T is safe because the allocation remains live and no API permits mutation without &mut self. Therefore sharing RawOwner<T> is sound precisely under T: Sync.

Exclusive access. get_mut(&mut self) -> &mut T relies on unique access to the owner and never exports another pointer. The returned reference cannot outlive the owner borrow.

Transfer. Moving the wrapper moves unique ownership. The destination may access or destroy T, so T: Send is required.

Destruction. Box::from_raw reconstructs the one owner and drops it once. There is no consuming raw-parts API, clone, pointer replacement, or partial-empty state.

Unwinding. Neither accessor changes representation. A panic in user code holding a reference follows ordinary T rules; the owner’s destructor remains valid. If future methods introduce partial movement, this conclusion must be reopened.

Reentrancy. The type does not call user code while mutating its representation. Dropping T can run arbitrary T::drop, but ownership has already moved into the temporary Box, and RawOwner exposes no usable self during its destructor.

This proof is small because the API is small. Adding an iterator, raw escape hatch, pinning promise, custom allocator, callback, or shared mutation expands it. The correct response is not a longer // SAFETY paragraph at the impl line; it is a safety-case update that connects each new operation to the affected premises.

Tests support the proof without replacing it

The fixture moves RawOwner<Counted> into a scoped thread and observes one destructor. That checks an important path, but it cannot prove absence of all races or double frees. Compile-time assertions or compile-fail fixtures can verify intended Send/Sync boundaries for representative types. Miri can explore some undefined behavior in executed paths. Concurrency model checkers can explore interleavings when the abstraction uses atomics or locks. Panic injection can exercise every mutation step.

Build a verification matrix around proof premises:

Premise Useful evidence What remains reasoning
conditional transfer positive thread test; negative compile test completeness of all owned state
exactly-one destruction drop counters across normal and panic paths unexecuted paths and API evolution
mutation rollback injected panic at each step whether injection points are complete
no reentrancy nested callback attempt foreign control transfers and async retention
marker relationship variance/auto-trait compile probes whether marker matches semantics
no data race model checking or sanitizer on supported targets target and tool coverage

Do not add an unsafe implementation merely to make a compile-time assertion pass. The assertion records the intended public property; the safety case must justify it independently.

Operational consequences of the composed contract

Thread safety affects more than undefined behavior. A value that can move freely may cause teardown on an arbitrary worker, changing latency, affinity, and observability. Record the owner or resource identifier in explicit close operations, not in a destructor that may run during panic. Avoid blocking indefinitely in Drop; a worker stalled in cleanup can exhaust a pool.

Panic policy must match build and service policy. With panic=abort, unwind guards do not run, so process termination is the containment model. With unwinding, invariants must survive and supervisory code must decide whether to retry, poison, quarantine, or terminate. Libraries generally should not assume the final binary’s panic strategy unless their platform contract fixes it.

Callback policy affects deadlocks and tail latency. Calling outward under a lock lets unknown code extend the critical section and reverse lock order. Calling after releasing the lock may expose a newer state than the callback payload. Snapshot or queue designs make that trade explicit. Instrument callback duration and nested-entry rejection if either is operationally significant.

Unsafe trait changes deserve the same change control as representation changes. Require an unsafe-code owner, list downstream generic consumers, rerun negative auto-trait tests, inject panics into new mutation steps, and inspect destructor behavior on every supported thread context. A compiler upgrade may improve diagnostics or tools, but it does not certify the semantic proof.

Assemble the first-pass audit packet

Take a wrapper that owns a foreign or heap resource through a raw pointer and has a manual Send or Sync implementation. Produce six artifacts:

  1. Trait contract: state exactly what safe consumers may assume, including where methods and Drop may run.
  2. Ownership map: identify the allocation source, logical owner, aliases, transfer points, and matching deallocator.
  3. marker and variance record: explain every PhantomData or non-send marker and show intended lifetime substitutions.
  4. operation ledger: include construction, each method, callbacks, raw escape hatches, panic exits, and destruction.
  5. unwind/reentrancy trace: inject failure at every temporarily invalid state and attempt one nested callback.
  6. evidence boundary: list compiler probes, tests, Miri/model-checking runs, unsupported targets, and premises still established only by review.

Reject the implementation if its generic bounds omit logically owned state; a callback can observe partial mutation; destruction can run on a forbidden thread; a panic reaches Drop with an invalid pointer or initialization count; or the marker encodes a more permissive relationship than the API supports. Prefer restoring auto-trait derivation by changing the representation when possible.

This packet is only the first pass. Before calling the review complete, attack the proof with implementations that compile, then record which maintenance changes reopen which premises.

Three implementations that compile and still fail review

The most useful red-team cases are designs whose surface looks disciplined.

First, consider a wrapper around a thread-affine C handle. It stores a non-null pointer, never aliases mutable access, and calls the correct release function. A manual Send implementation may still be wrong because the foreign library requires both use and destruction on the creating thread. Wrapping every method in a mutex prevents simultaneous calls but does not preserve affinity. Recording the creator thread and panicking on the wrong thread also does not justify Send: safe transfer has already permitted the value to arrive where its destructor cannot run legally. Keep the handle non-sendable, or transfer commands to a dedicated owner thread and make the sendable value a channel endpoint rather than the handle.

Second, consider unsafe impl<T: Send> Sync for Owner<T> justified by “mutation requires a lock.” The generic bound is wrong: shared access can expose &T, so it requires T: Sync. If the wrapper exposes synchronized mutation but never an unsynchronized shared reference, its internal proof may support a different bound, but the method set—not the author’s intention—decides. Enumerate every safe value reachable from &Owner<T>, including iterator items, guards, callbacks, debug formatting, and dereference coercions. One returning &T restores the ordinary T: Sync requirement.

Third, consider a container that poisons itself if user comparison panics during reordering. Poisoning prevents later methods, but its destructor walks an array using the pre-operation initialized length. If the operation moved an element out before the panic, drop reads an uninitialized slot. The poison flag protects application logic after recovery; it cannot make destructor access valid. A hole guard must track the moved element, restore it or adjust the initialized region during unwind, and only then mark the higher-level ordering invariant poisoned.

These cases show why “we have a lock,” “we catch panics,” and “the pointer is uniquely owned” are ingredients, not conclusions. The proof ends at every safe observer and cleanup path.

Review changes as deltas to the proof

Unsafe abstractions often become unsound through ordinary maintenance rather than an obviously dangerous patch. Treat the safety case as a dependency graph and ask what a change invalidates.

Adding impl Clone to a raw owner reopens ownership and destruction. Adding Debug can invoke user formatting while locks or temporary states are active. Adding a cached field changes auto-trait derivation and perhaps panic points. Returning an iterator adds reference creation, aliasing, and lifetime obligations. Changing T to MaybeUninit<T> moves validity into every accessor and destructor. Adding a callback reopens unwind and reentrancy. Switching an allocator changes the matching deallocation contract. Making shutdown asynchronous changes the thread and time at which resources die.

A compact change-control record should contain:

  • the old invariant and the revised invariant;
  • new safe observations and new unsafe operations;
  • auto-trait results before and after, including representative negative types;
  • newly reachable panic, cancellation, callback, and destructor paths;
  • whether the old safety comments still state sufficient local premises;
  • evidence rerun and configurations not exercised.

This is also why private unsafe internals deserve documentation. The public API tells callers what they can rely on. Internal safety notes tell maintainers which seemingly harmless edits would invalidate those guarantees. Keep comments next to the unsafe operation concise, and keep the abstraction-wide argument where reviewers can see dependencies.

A senior review checklist

  • Is the trait unsafe because memory safety truly depends on implementers, and is that dependency precise?
  • Which safe generic consumer relies on each clause?
  • Can auto-trait derivation replace a manual positive implementation?
  • Do generic bounds cover logically owned, borrowed, and callback-reachable state?
  • Do markers truthfully encode ownership, borrowing, variance, drop checking, and auto-trait intent?
  • Can Drop run after transfer, during unwind, after partial initialization, or on a forbidden thread?
  • Are resources destroyed or transferred exactly once with the matching allocator and order?
  • Which panic guarantee does each invariant-sensitive method provide?
  • Does every unwind path first restore memory validity before applying poison or recovery policy?
  • Can user code run while a lock is held or an invariant is open?
  • Is nested entry rejected, deferred, snapshotted, or deliberately supported?
  • Do tests map to proof premises, and are tool limitations recorded?
  • Has the proof been updated for every API, field, allocator, callback, or shutdown change?

One final discipline keeps this checklist useful: distinguish safety from service correctness. A poisoned cache may return an error forever and remain memory-safe. A Send handle may obey Rust’s aliasing rules yet violate a vendor’s license or latency requirement. A destructor may leak after an emergency path rather than double-free, preserving memory safety while exhausting capacity. Record these as separate layers. The unsafe proof establishes the conditions under which safe Rust cannot cause undefined behavior. The component contract then adds ordering, availability, data integrity, resource, and operational requirements. Mixing the layers either hides a safety premise inside vague service language or overclaims that sound unsafe code makes the whole system correct.

During incident review, preserve both layers. Capture the exact binary, target, compiler, panic strategy, feature set, thread trace, callback ordering, and destructor path. Minimize the unsafe core without removing the failing control flow. A test that stops reproducing after replacing the callback or panic is not evidence that the pointer logic was innocent; it may have removed the only route to the invalid state.

Sources and version notes

Examples target Rust 2024 and the fixture declares Rust 1.85 as its MSRV; verification uses the book snapshot Rust 1.97.0 as well. Primary references are the Rust Reference on special types and traits, behavior considered undefined, and destructors; and standard-library documentation for Send, Sync, PhantomData, and catch_unwind. Variance, drop-checking escape hatches, and the evolving aliasing model require especially careful version review. Tool success is evidence about executed configurations, not a soundness proof.