The Destructor That Kills Your Program: C++ Exception Mechanics You Need to Understand
Source: isocpp
Destructors are the backbone of RAII, C++‘s most important resource management idiom. The entire promise of RAII is that resources get released deterministically, even when exceptions unwind the stack. But that promise quietly breaks down in one specific scenario: when the cleanup itself can fail. Understanding what happens at that point requires looking at how the C++ runtime handles exceptions at a mechanical level, and why the language made a decisive choice in C++11 that trades flexibility for safety.
Stack Unwinding and Why Two Exceptions Is One Too Many
When an exception is thrown and not immediately caught, the runtime begins stack unwinding: it walks back up the call stack, calling destructors for every local object in each frame as it goes. The exception stays “in flight” the entire time.
Now consider what happens if one of those destructors throws. The runtime is already managing one active exception. A second throw creates an ambiguity the language specification resolves by giving up entirely: std::terminate() is called, which by default calls std::abort(). Your program dies. No cleanup for remaining objects, no catch blocks, nothing.
This is not a quality-of-implementation issue or a compiler quirk. It is the specified behavior, present since at least C++98 and codified clearly in every subsequent standard. The C++ standard cannot handle two simultaneously active exceptions in a single thread, so it terminates rather than attempt to pick one.
What C++11 Changed: Destructors Are noexcept by Default
Before C++11, the implicit assumption was that destructors might throw, and the behavior during stack unwinding was the mechanism that discouraged it. C++11 made this formal: all destructors are implicitly noexcept(true) unless the destructor itself or any base class or member destructor is explicitly declared noexcept(false).
The consequence is immediate. Even if no other exception is active, a destructor that throws when it is marked noexcept (which now means almost all destructors) will trigger std::terminate() directly. The exception never propagates. The program just dies.
struct Resource {
~Resource() {
// This destructor is implicitly noexcept(true).
// Throwing here calls std::terminate() unconditionally.
throw std::runtime_error("cleanup failed");
}
};
int main() {
Resource r;
// terminate() is called when r's destructor runs at end of scope.
return 0;
}
You can opt out with noexcept(false), but you should understand what you are signing up for. A throwing destructor with noexcept(false) behaves like any other throwing function when called during normal execution: the exception propagates. However, during stack unwinding, the problem remains. cppreference confirms that if a destructor called during stack unwinding exits via an exception, std::terminate() is still called. Opting into a potentially-throwing destructor does not protect you in the case that actually matters most.
The Silent Failure: std::fstream and Friends
The most common real-world encounter with this design constraint is file I/O. std::fstream’s destructor flushes and closes the underlying file handle, but if the flush fails due to a full disk or a disconnected network filesystem, the error is silently swallowed. This is not a bug. It is the intended design, because the alternative is a destructor that throws during cleanup.
This becomes a real data-loss hazard on network filesystems or when writing to pipes. A program that looks like it successfully wrote a file might have lost the last few kilobytes because the destructor had nowhere to report the error. The correct fix is to call close() explicitly before the destructor runs, check its return value or catch its exception, and let the destructor serve as a best-effort fallback. Herb Sutter documented this two-phase design in Exceptional C++ and it remains the standard recommendation for resources with fallible cleanup:
class ManagedFile {
public:
explicit ManagedFile(const std::string& path)
: file_(path, std::ios::out), committed_(false) {}
void write(const std::string& data) { file_ << data; }
// Explicit commit: callers who care about errors call this.
void commit() {
file_.close();
if (!file_) throw std::runtime_error("write failed");
committed_ = true;
}
~ManagedFile() {
if (!committed_) {
// Best-effort cleanup. Swallow errors.
try { file_.close(); } catch (...) {}
}
}
private:
std::fstream file_;
bool committed_;
};
The destructor does not throw. The error path goes through the explicit commit() method, which callers invoke when they care about failure.
Detecting Your Context: std::uncaught_exceptions()
C++17 introduced std::uncaught_exceptions() (plural, returning an int), replacing the older std::uncaught_exception() (singular, returning bool) that was deprecated in C++17 and removed in C++20. The plural form returns the count of currently active exceptions, not just a boolean.
The reason the count matters is subtle. Consider a destructor called during normal scope exit inside a function that was itself invoked from within a catch block. The old boolean API would return true even though this particular destructor is not being called during stack unwinding from an active exception. The count lets you compare the exception count at construction time with the count at destruction time:
class OnFailure {
public:
template <typename F>
explicit OnFailure(F&& f)
: fn_(std::forward<F>(f)),
exception_count_(std::uncaught_exceptions()) {}
~OnFailure() noexcept(false) {
// Only run if we're exiting due to a new exception.
if (std::uncaught_exceptions() > exception_count_) {
fn_();
}
}
private:
std::function<void()> fn_;
int exception_count_;
};
This is the core of the scope_fail pattern, and it is what makes constructs like Facebook’s Folly SCOPE_FAIL work correctly. The symmetric scope_success variant fires its callback only when the exception count stays the same, meaning the scope exited normally.
These three patterns, scope_exit (always fires), scope_fail (fires on exception only), and scope_success (fires on normal exit only), come from Andrei Alexandrescu and Petru Marginean’s original ScopeGuard paper in Dr. Dobb’s. They have lived in Library Fundamentals TS v3 as std::experimental::scope_exit, std::experimental::scope_fail, and std::experimental::scope_success for years without making it into the standard proper. D went further with native scope(exit), scope(failure), and scope(success) language-level statements, which makes the pattern considerably more ergonomic than anything C++ currently offers.
What You Should Actually Do
Destructors should not throw. Mark them noexcept explicitly if you want the compiler to enforce this, or rely on the C++11 default. If your destructor calls something that might throw, wrap it:
~ResourceWrapper() noexcept {
try {
resource_.release();
} catch (const std::exception& e) {
log_error("release failed: ", e.what());
} catch (...) {
log_error("release failed: unknown exception");
}
}
When cleanup failure is semantically meaningful, expose it through an explicit method and use the destructor as a fallback. For transaction-like patterns, track a committed flag:
class Transaction {
public:
void commit(); // Throws on failure.
void rollback(); // Throws on failure.
~Transaction() noexcept {
if (!committed_ && !rolled_back_) {
try { rollback(); }
catch (...) { /* cannot propagate from here */ }
}
}
private:
bool committed_ = false;
bool rolled_back_ = false;
};
Callers who care about whether the transaction succeeded call commit() explicitly. The destructor is the last resort for processes that die before reaching the commit point.
If you need the scope_fail pattern without pulling in Folly, the minimal implementation using std::uncaught_exceptions() is straightforward and self-contained. It solves the real problem of running cleanup code only on the failure path without any per-callsite boilerplate.
The Underlying Tradeoff
The decision to make destructors noexcept by default reflects a deliberate language philosophy: correctness of the common case over expressiveness of the edge case. Allowing destructors to throw freely would make every use of RAII a potential double-exception hazard. The language designers chose to make the safe behavior the default and require explicit opt-in for the dangerous one.
Sandor Dargo’s writeup on isocpp.org frames this as fundamental to finding mastery in C++, and that framing holds. The destructor exception rules are one of those corners of the language where the behavior feels surprising until you understand the constraint that forced the design. Once you see why two simultaneous exceptions are unresolvable, the entire set of rules falls into place: the default noexcept, the terminate() on violation, the two-phase cleanup pattern, the uncaught_exceptions() count trick. They are all responses to the same underlying limitation.
Design your types accordingly, and expose failure paths through explicit interfaces rather than through the destructor.