Skip to content

The Rust Engineering Handbook / Chapter 36

Panics, Unwinding, Abort, and Exception Safety

Choose panic policy deliberately and preserve type, resource, synchronization, and foreign-boundary invariants when normal control flow stops.

Decide what survives before calling untrusted work

A collection maintains one public invariant: its committed values are sorted and unique. An update method clones the committed vector, lets a caller-supplied closure edit the clone, normalizes it, and swaps it into place.

The review question is not initially “Should we catch a panic?” It is:

If the closure panics after adding a value, what state may the caller observe next?

Three answers are possible. The process terminates, so no in-process caller observes the collection. The panic unwinds and the original state remains unchanged. Or unwinding continues after leaving the collection valid but modified. Each can be defensible under a stated contract. “Whatever the partially executed code happens to leave” is not a contract.

The fixture selects a strong guarantee under unwinding:

pub fn stage_then_commit<F>(&mut self, operation: F)
where
    F: FnOnce(&mut Vec<u64>),
{
    let mut staging = self.committed.clone();
    operation(&mut staging);
    staging.sort_unstable();
    staging.dedup();
    self.committed = staging;
}

If operation returns, normalized staging becomes committed state. If it panics and the profile unwinds, the local staging vector is dropped while self.committed was never mutated. That behavior follows from ordinary ownership and control flow; no recovery framework is required.

The central contract for panic-safe design is:

Code that can be crossed by unwinding must keep every value safe to drop and every externally observable invariant within its documented guarantee at each possible panic point. An abort profile changes cleanup and containment behavior, but it does not justify memory unsafety or invalid values before termination.

Panic is not a second form of Result. It is an abnormal control path for violated assumptions, failed assertions, explicit panic macros, selected standard-library operations, and propagation from code you call. The design work is deciding when such a path is appropriate and what remains valid if it begins.

Panic policy starts with the failure classification

Chapter 34 separated invalid input, unavailable resources, partial completion, and programmer defects. That separation controls panic use.

Expected failures that a caller can handle belong in Result or another explicit outcome. A parser should not panic on malformed untrusted bytes within its documented input domain. A library should not panic because a file is absent. A bounded service should not panic because admission control rejects excess work. Turning those conditions into panics removes typed recovery and can turn ordinary hostile input into process termination.

Panic is appropriate when execution reaches a condition the code’s own invariant says cannot occur, when an assertion deliberately detects a defect, or when an API explicitly documents a precondition violation as a panic rather than a recoverable outcome. Tests also use panic as a direct failure signal. Even then, a public function should document panic conditions and avoid surprising panics on valid inputs.

unwrap and expect express this choice. In production code, expect is useful when its message names the established invariant:

let classified = accepted
    .checked_add(rejected)
    .expect("accepted and rejected counts must fit usize");

The message should explain why failure indicates a defect, not merely restate “addition failed.” If the counts originate from untrusted input and overflow is part of the domain, Result is the correct contract instead.

Some host processes cannot allow one extension, request, or test case to terminate the whole host. They may establish a containment boundary around code whose contract says it may panic. Containment does not reclassify the panic as a normal error, and it does not prove the captured component remains usable.

Unwind and abort are different control-flow contracts

Rust supports panic strategies selected by compilation profile and target capabilities. Under an unwind strategy, a panic begins stack unwinding: stack frames are exited and local values are dropped as control searches for a catch boundary or reaches the thread root. Under an abort strategy, the process terminates without Rust stack unwinding.

The example package makes the policy visible:

[profile.release]
panic = "abort"

Its test-profile harness exercises unwinding; Cargo’s stable test harness does not apply the package’s release panic = "abort" setting. Its release executable demonstrates ordinary error reporting and never deliberately panics, because a release panic would terminate the process. Selecting abort can reduce binary size or match a system policy that treats any panic as process-fatal. The cost is that unwinding containment and normal Rust destructor cleanup do not occur on that path.

Do not write cleanup logic that is required for memory safety only in Drop; safe Rust’s abstractions must remain sound even when the process aborts. But resource and operational consequences differ. Buffered output may not flush. Temporary files may remain. Locks held in shared external systems are not released by Rust destructors. In-process cleanup guards cannot run. The operating environment must tolerate or repair those outcomes.

Unwind is not a promise that every destructor completes. A destructor can panic while unwinding; Rust cannot continue ordinary unwinding through a second panic and typically aborts the process. Destructors should therefore avoid fallible work that can panic. Provide explicit close, finish, commit, or shutdown operations when callers must observe failure, and let Drop perform bounded best-effort cleanup that preserves safety.

Platform and target support matter. Some targets or foreign boundaries do not support the same unwinding behavior. Treat the profile and boundary policy as build configuration that tests and deployment records must verify, not as a fact inferred from debug behavior on one workstation.

Trace the first panic point, not the final symptom

Figure 36-1 combines the two decisions in the opening example. The upper bands separate an unwind profile from an abort profile. The lower band locates the collection’s commit point.

Under an unwind profile, a callback panic drops staging and intervening frames before reaching a narrow catch boundary while committed values remain 1 and 3. A separate abort-profile path terminates immediately with no destructor execution. Below, staging contains 1, 3, and 34, but a panic before the commit point leaves committed state at 1 and 3; only validation followed by the commit point yields committed 1, 3, and 34.

Read the figure in time. The callback mutates only staging. The panic begins before the vertical commit point. During unwind, staging drops, followed by locals in outer frames. The collection’s committed vector stays [1, 3]. The catch boundary observes a panic only after those drops. Under abort, there is no path through those destructor boxes or the catch boundary.

This ordering shapes reviews. For every operation that temporarily weakens an invariant, identify:

  1. the first instruction that makes state incomplete or inconsistent;
  2. every call, allocation, formatting operation, comparison, callback, index operation, or user-defined destructor that can panic before restoration;
  3. what Drop will see for each live value;
  4. which state another observer can access during or after unwinding;
  5. the commit point after which the new state satisfies the full invariant.

Allocation can panic under some failure modes. User-defined comparison and hashing code can panic. Indexing can panic. Formatting in a diagnostic path can panic if user implementations do. “There is no explicit panic! in this function” is not a panic-safety argument.

Basic and strong guarantees are useful analogies—with limits

The exception-safety vocabulary used in other languages helps name Rust API promises, provided we do not equate Rust panic with ordinary recoverable exceptions.

  • No-throw / no-panic guarantee: the operation promises not to panic for its documented inputs and environment. This requires auditing everything it calls; it is stronger than omitting panic!.
  • Strong guarantee: if unwinding begins, externally observable state is unchanged, as though the operation had not started.
  • Basic guarantee: if unwinding begins, values remain valid and resources remain owned correctly, but externally visible state may have changed.
  • No guarantee beyond process termination: the design relies on abort or immediate host termination and does not promise continued in-process use. Safety obligations still hold until termination.

These are API and invariant guarantees, not built-in Rust markers. UnwindSafe is related but does not certify a particular data structure’s semantic rollback. Conversely, a type can maintain a well-reasoned strong guarantee even if a closure capture requires an explicit AssertUnwindSafe at a narrowly reviewed test boundary.

The staged collection buys the strong guarantee by cloning. That cost may be unacceptable for a large structure. Alternatives include:

Design Unwind outcome Main cost Suitable when
clone, mutate staging, swap strong guarantee allocation and cloning state is moderate and rollback simplicity matters
record inverse operations strong guarantee if rollback cannot panic log complexity and rollback proof changes are sparse and reversible
maintain a valid intermediate state basic guarantee callers may observe partial change partial progress is documented and usable
consume self, return replacement old owner unavailable during work API and ownership redesign operation naturally constructs a new value
abort on panic no in-process continuation process loss and skipped cleanup deployment isolates restart and accepts loss

The cheapest correct guarantee depends on what observers require. A private cache may accept being cleared after a panic. A ledger index used to authorize decisions may need unchanged state or process termination. Do not pay for rollback merely because “strong” sounds better; do not accept partial mutation merely because the type remains memory-safe.

Guards encode restoration in ownership

Staging is one guard pattern: the uncommitted value owns tentative changes, and normal control flow performs the commit. Other operations need a guard that restores metadata on drop.

For example, an in-place vector algorithm might temporarily set a logical length aside while moving elements. Safe code can often express the transformation through existing operations whose internal invariants are already guarded. Unsafe collection implementations must go further: every panic point must leave initialized elements, length, capacity, and ownership consistent enough that drop neither reads uninitialized memory nor double-drops an element.

A restoration guard should have a small state machine:

armed -> commit -> disarmed
armed -> unwind -> Drop restores

Its destructor must not rely on fallible allocation, user callbacks, or operations likely to panic. If restoration itself can panic, a panic during unwind may become an abort. Keep guard state sufficient to restore deterministically.

mem::forget and process abort mean Drop is not a universal liveness guarantee. Safe abstractions cannot require destructors to run in order to prevent undefined behavior. They can use Drop for resource cleanup and restoration on ordinary return and unwind while documenting leak or abort consequences.

The same reasoning applies beyond collections:

  • write a temporary file, sync as required, then rename at the commit point;
  • build a new routing table, validate it, then publish one pointer or handle;
  • reserve capacity before altering externally visible counters;
  • mark a database transaction committed only after the durable commit succeeds;
  • use an RAII guard to clear an “update in progress” flag on unwind.

Chapter 37 will broaden commit points to cancellation and retries. Panic safety is the first rehearsal: interruption can occur at a point the happy path did not choose.

catch_unwind is a narrow containment tool

std::panic::catch_unwind invokes a closure and returns either its value or a panic payload when the panic unwinds. It catches unwinding panics, not aborting panics. It is not guaranteed to catch every cause of process termination, and interacting with a foreign exception is subject to boundary-specific rules.

The fixture uses it to witness the collection guarantee:

let before = ledger.clone();

let panicked = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
    ledger.stage_then_commit(|staging| {
        staging.push(34);
        panic!("injected callback defect");
    });
}))
.is_err();

assert!(panicked);
assert_eq!(ledger, before);

AssertUnwindSafe is a manual assertion about captured state crossing the containment boundary. It does not add synchronization or rollback. Here, the test has separately established that mutation occurs on staging and that committed state changes only after the closure returns. Applying AssertUnwindSafe broadly around a server loop to silence a bound would hide the exact captures that need audit.

Good containment boundaries are architectural:

  • a test harness isolating one test;
  • a plugin host that can discard the entire plugin instance after a panic;
  • a thread boundary that reports failure to a supervisor;
  • an FFI adapter required to prevent unwinding across a non-permitting ABI;
  • a request worker whose owned request state can be abandoned without reusing suspect shared state.

After catching, assume less. The panic hook may already have emitted output. Locks may be poisoned. External side effects completed before the panic do not roll back. Captured values may satisfy memory safety while violating domain expectations. A host should discard or reinitialize the failed component unless its invariant analysis proves continued use is valid.

Catching every panic at the top of an application and converting it to Err("unknown") hides defects, defeats fail-fast signals, and may keep corrupt logical state in service. Contain only where the recovery unit is explicit.

Hooks observe panics; they do not recover

A panic hook runs when a thread panics, before the runtime either unwinds or aborts. The default hook prints a message and location information. An application may install a hook to emit a structured fatal event, attach correlation context, redact output, or integrate with crash reporting.

Hooks are global process policy. Libraries should not replace the application’s hook as a side effect of ordinary initialization. A host that installs one must consider concurrent panics, reentrancy, allocation failure, formatting failure, secret exposure, and what happens if the hook panics.

The hook should remain bounded. It cannot assume normal service infrastructure is healthy. Sending synchronously through a full telemetry queue can deadlock or block termination. Acquiring a lock held by the panicking thread can deadlock. Formatting arbitrary captured objects can invoke more code. Prefer a small stable event with panic location and approved context, then let external crash collection and ordinary diagnostics supply more evidence.

A hook also does not imply one operator event in all designs. The Chapter 35 “log once” rule applies to recoverable error handling. A panic hook may emit a fatal observation before a catch boundary decides to quarantine a component. Name these signals distinctly so an operator can correlate “panic observed” with “plugin quarantined” rather than count them as duplicate request failures.

Mutex poisoning is evidence, not a verdict

The standard library’s Mutex and RwLock can report poisoning when a panic occurs while exclusive access is held. Poisoning warns that protected invariants may be incomplete. It does not prove corruption, repair the state, or make unsafe code sound.

A caller receiving PoisonError has choices:

  • terminate or propagate because the protected state is critical;
  • inspect and repair the state, then clear or replace it according to the type’s policy;
  • recover the guard with into_inner when analysis proves the invariant remained valid;
  • discard the entire component and rebuild it from an authoritative source.

Ignoring poison with an unconditional into_inner() is a context-free fix. So is treating every poison as certain data loss. Document the protected invariant, locate the panic points inside the critical section, and choose a recovery unit.

Poisoning is advisory and tied to observed panic context. It should not be the only safety mechanism for unsafe code. Some panic interactions may not trigger poisoning in the way a broad mental model expects. The protected data structure must maintain its own validity and soundness; poison supports domain-level caution.

Locks from third-party synchronization libraries may use different poisoning policy. State the behavior of the actual primitive instead of generalizing from std::sync::Mutex.

Foreign boundaries need an explicit unwind ABI contract

Unwinding across a foreign-function interface boundary is not a detail to discover during an incident. The ABI declaration and both languages’ runtimes determine what is permitted.

For ordinary extern "C" boundaries, do not allow a Rust panic to escape into foreign code. Use a narrow containment boundary when the Rust side is built to unwind, translate the result into an explicit status or error object, and ensure all captured state is safe to discard. If the build uses abort, a panic terminates instead; catch_unwind cannot translate it.

Rust also has unwind-permitting ABI forms such as extern "C-unwind" for explicitly designed interoperation. That label does not make arbitrary cross-language exceptions safe. Ownership, destructor, runtime, compiler, and platform contracts still need review, and catching foreign exceptions with catch_unwind has specified-but-limited outcomes rather than a universal portable guarantee.

An FFI wrapper should answer:

  • Which side may initiate unwinding?
  • Which ABI permits it?
  • Who owns every value if control does not return normally?
  • Which destructors run in each runtime?
  • How is a Rust panic reported to the foreign caller?
  • Does the release panic strategy match the wrapper’s containment design?
  • Which cross-language tests execute the abnormal path?

The safest default is an explicit status boundary with no unwinding crossing it. Use an unwind-permitting ABI only when the integration needs it and specialists can validate the full runtime contract.

Panic can be a denial-of-service primitive

Memory safety does not prevent an attacker from triggering expensive panic handling or process termination. Any panic reachable from untrusted input can become a denial-of-service path, especially under panic = "abort" or a supervisor that enters a crash loop.

Review indexing, arithmetic, parser assumptions, allocation sizes, recursion depth, unwrap, and expect on paths controlled by network data, files, plugins, or tenants. Convert expected invalid conditions to bounded errors. Fuzzing and property tests should include “does not panic for any accepted input” as an explicit property where the API promises it.

Containment has resource costs too. Repeatedly catching plugin panics while leaving external tasks, file handles, or allocations alive can leak capacity. A panic hook that captures a full backtrace for every hostile request can amplify CPU and storage use. Rate limits, isolation, restart budgets, and circuit breakers belong in the operational policy.

Crash-only recovery can be sound when the process owns a small replaceable shard, persists state safely, and a supervisor applies bounded backoff. It is dangerous when the process is a large shared host, restart repeats corrupt input, or side effects are not idempotent. The panic strategy is therefore part of availability architecture, not merely a binary-size option.

Tests must exercise abnormal control flow deliberately

Panic tests have several distinct jobs.

#[should_panic] is appropriate when the contract intentionally panics and the test needs to prove that fact. An expected-message substring can localize the assertion, but tests should not depend on the complete default hook rendering. catch_unwind is more useful when a test must inspect state after unwinding, as the fixture does.

Test at least these properties when applicable:

  • a documented invalid precondition panics, while valid edge cases do not;
  • state satisfies the chosen basic or strong guarantee after a witnessed unwind;
  • guards restore metadata and release owned resources;
  • a poisoned lock follows the documented recovery decision;
  • panic payloads or hook output do not disclose secrets;
  • an FFI wrapper converts or terminates according to its ABI and profile;
  • the release abort profile is built and smoke-tested without trying to catch its panic in-process;
  • supervisors apply a bounded restart policy rather than an immediate infinite loop.

Do not write an abort test inside the ordinary test process unless the harness deliberately launches a subprocess. Aborting would terminate the harness. A subprocess test can assert termination and captured output, but exact status semantics may be platform-specific and should be labeled.

The fixture’s four deterministic tests need no sleep, network, or third-party dependency. One proves a panic before commit leaves the collection equal to its snapshot. Another proves normal completion commits normalized state. Those tests verify the selected data-structure contract; they do not prove every callback, allocator failure, target, or process policy.

Designs that compile but weaken the system

Panic for malformed input. A parser indexes without checking because tests use valid frames. Under abort, one bad frame kills the process. Validate at the trust boundary and return a typed input error.

Catch and continue globally. A top-level loop catches every panic and reuses all shared state. Memory safety may hold while domain invariants do not. Define a smaller recovery unit and discard suspect state.

Mutate, then call user code, then repair. A callback panics while an index and backing store disagree. Stage first, maintain a valid intermediate state, or install a restoration guard.

Fallible destructor as commit. Drop performs network I/O and panics on failure. Callers cannot observe the error, and a panic during unwind aborts. Use an explicit completion method; reserve Drop for bounded cleanup.

Assume abort runs cleanup. Temporary files, buffers, and leases rely on destructors. The release profile aborts, leaving operational residue. Design external recovery and atomic commit protocols.

Ignore poison mechanically. into_inner() appears at every lock acquisition. The program resumes with an unaudited invariant. Centralize recovery policy around the protected type.

Catch an aborting build. A plugin host uses catch_unwind, but its release profile sets panic = "abort". The intended isolation boundary does not exist. Test the exact profile deployed.

Let a panic cross an ordinary foreign ABI. Rust frames unwind into a caller that did not permit it. Contain before the boundary or select and validate an unwind-aware ABI.

Audit panic safety as an invariant table

Before approving a panic-capable component, ask:

  • Which failures are expected Result outcomes, and which conditions are defects?
  • What panic strategy does each shipped profile and target use?
  • At every possible panic point, are all live values safe to drop?
  • Does the API promise unchanged state, valid partial state, or process termination?
  • Do callbacks, comparisons, formatting, allocation, indexing, or destructors introduce hidden panic points?
  • Are restoration guards deterministic and non-panicking?
  • Is every catch_unwind boundary narrow, and is captured state audited for unwind safety?
  • What state is discarded, repaired, or quarantined after a catch or poison report?
  • Can a hook block, deadlock, allocate excessively, or disclose sensitive data?
  • Can untrusted input trigger panic, expensive reporting, or a restart loop?
  • Does each FFI boundary state whether unwinding is forbidden or explicitly permitted?
  • Do tests run the abnormal path under the same relevant profile assumptions as production?

Exercise: choose and prove an update guarantee

Level: Review board. You inherit a collection with a Vec<Entry> plus a HashMap<Id, usize>. Its update method removes an entry from the vector, calls a user-supplied normalization closure, pushes the returned entry, and repairs the index. The closure can panic. Reads continue in the same process after a request-level containment boundary catches it.

Deliver:

  1. an invariant table naming the vector/index relationship, every temporary state, each panic point, what Drop sees, and who can observe the state;
  2. a decision between a basic guarantee, strong guarantee, component discard, or process termination, justified by availability and data-authority constraints;
  3. a repair using staging, a non-panicking restoration guard, or a reconstructed replacement;
  4. tests that inject a panic before and after the proposed commit point and inspect post-unwind state;
  5. a release-profile decision for unwind or abort and its operational cleanup consequences;
  6. an FFI note if the normalizer may be supplied by foreign code.

Reject an answer that adds catch_unwind without changing the unsafe mutation window. A valid solution may choose process termination when state is security-critical and cheap to reconstruct, but it must address persisted side effects and bounded restart behavior.

Durable conclusions

  • Expected environmental and input failures belong in typed outcomes; panic normally signals a violated program assumption or documented panic condition.
  • Unwind runs Rust stack cleanup on its path; abort terminates without that unwinding. Build profiles must match the intended containment model.
  • Every possible panic point must leave live values safe to drop and domain state within its documented basic, strong, or termination guarantee.
  • Staging and restoration guards move rollback from hope into ownership and control flow.
  • catch_unwind observes only unwinding panics and is appropriate at narrow recovery boundaries, not as a blanket error mechanism.
  • Hooks observe; poisoning warns; neither proves recovery is safe.
  • Panic strategy affects FFI, denial-of-service exposure, resource cleanup, supervisor policy, and tests.

A panic is one way normal work stops. Cancellation is quieter and often more frequent: a future is dropped, a client disconnects, a deadline expires, or shutdown races a commit. Chapter 37 carries the same invariant and commit-point method into cancellation, idempotency, and cleanup.

Sources and verification notes

  • Rust standard library, std::panic, catch_unwind, AssertUnwindSafe, and set_hook.
  • Rust Reference, panic behavior and unwinding.
  • Cargo Reference, panic profile setting.
  • Rust standard library, Mutex poisoning and PoisonError.
  • Rust Reference, ABI strings and unwinding.
  • Executable source: examples/rust-engineering-handbook/part-06/robustness-boundaries-lab/, Rust 2024 Edition, no third-party dependencies, rust-version = "1.85"; the release profile declares panic = "abort".
  • The fixture README records checks on Rust 1.97.0 and the stated MSRV. Its test harness exercises the unwind guarantee; its release profile is built with abort policy, and the ordinary executable runs without deliberately aborting.