Chapter 32 — Crash on Exit
Chapter 32 — Crash on Exit¶
This chapter and the five after it work the way a ticket does: a symptom, the code it happened to, and no title that names the concept in advance. Work it cold, then open the fold; the thing it trains gets its name only after you have felt the need for it, on the far side of the diagnosis.
The ticket¶
Crash on exit (since 2.4.1). The app crashes when it is closed. Not every install: support cannot reproduce it, and neither can the developer who shipped 2.4.1. The customer's crash report shows a segmentation fault after
exit, in__cxa_finalize— aftermainhas already returned. 2.4.1 changed one thing in this area: an audit line is now written when the session closes.
A crash after main returns is a category of its own. Your code has
finished; every line you wrote has executed and none of it is on the screen.
And yet something of yours is still running — which is the first thing the
diagnosis has to explain.
The code it happened to¶
Four files. The logger is a session log — one heap block, appended to with
Recipe 5's snprintf, freed when the logger dies. It is a namespace-scope
global, the way loggers usually are:
// logger.h (2.4.1)
#pragma once
#include <cstddef>
class Logger {
public:
Logger();
~Logger();
Logger(const Logger&) = delete;
Logger& operator=(const Logger&) = delete;
void write(const char* line);
std::size_t lines() const;
private:
char* buffer_; // the session log, one heap block
std::size_t used_ = 0;
std::size_t lines_ = 0;
};
extern Logger g_logger; // one logger for the whole program
// logger.cpp (2.4.1) - the implementation, and the global itself
Logger g_logger;
Logger::Logger() : buffer_(new char[kCapacity]) { buffer_[0] = '\0'; }
Logger::~Logger() { delete[] buffer_; }
And 2.4.1's one change: an auditor object, also a global, that writes the closing line from its destructor —
// audit.cpp (2.4.1)
#include "logger.h"
class Auditor {
public:
~Auditor() { g_logger.write("audit: session closed"); } // added in 2.4.1
};
Auditor g_auditor;
main writes one line of ordinary work and exits. That is the whole
program. It compiles clean under -Wall -Wextra. It runs clean on the
developer's machine, and on every machine support tried.
Try it — before reading on¶
The ticket card is exercises/exitlab/TASK.md, with the full broken
listings to recreate in a scratch directory of your own (the files beside
the card are the fixed reference — no peeking). The steps, and do them in
this order:
- Build plain, run: exit 0. You have now reproduced support's experience.
- Build and run under the handbook's flags:
scripts/check.sh logger.cpp audit.cpp main.cpp— the sources build in the order written, and that order is the link order. - Again as
scripts/check.sh audit.cpp logger.cpp main.cpp— nothing changed but the order on the command line. - Read what you get the Chapter 31 way: which report shape, whose stacks, and what the frames below your own code are telling you.
- Fix it so both link orders run clean. That is the acceptance test — not "it stopped crashing here".
The diagnosis, walked through¶
Show the walkthrough — work the ticket cold first
Plain build: exit 0 in both link orders, on this machine (macOS/arm64). The customer still crashes. Keep that pair of facts; the theory section owes you an explanation for it.
Under -fsanitize=address,undefined, the first link order also runs clean.
The second — audit.cpp logger.cpp main.cpp — does not:
ERROR: AddressSanitizer: heap-use-after-free ...
WRITE of size 21 ...
#6 in Logger::write(char const*) logger.cpp:17
#7 in Auditor::~Auditor() audit.cpp:5
#9 in __cxa_finalize_ranges (libsystem_c.dylib)
#10 in exit (libsystem_c.dylib)
freed by thread T0 here:
#1 in Logger::~Logger() logger.cpp:11
#3 in __cxa_finalize_ranges (libsystem_c.dylib)
previously allocated by thread T0 here:
#1 in Logger::Logger() logger.cpp:10
Read it with Chapter 31's index. Three stacks — a use-after-free. The
write is Logger::write, called from Auditor::~Auditor. The freed by
is Logger::~Logger. And the tell this chapter adds to your index: both of
those stacks sit on __cxa_finalize_ranges, under exit. The write is a
destructor. The free is a destructor. Two destructors, wrong order — the
report is not describing a memory bug so much as a scheduling bug, and the
schedule is the exit sequence itself.
So: the logger died before the auditor, the auditor's last words went into freed memory, and the only thing that decided who died first was the order of two object files on a link line. Which nobody chose, and nobody tests.
What the standard actually promises¶
You have spent years never once scheduling a global. In C#, a static constructor runs before the type's first use — lazily, thread-safely, in an order the runtime works out; teardown belongs to the runtime too, which is exactly why the one thing every C# developer knows about finalizers is do not touch other objects from them. The runtime made initialization order a non-topic, and made destruction order somebody else's problem. C++ hands you both.
Surprise for C# devs: there is no runtime sequencing your globals. Within one translation unit they construct top to bottom — and across translation units the order is unspecified: the standard refuses to say, and in practice the link line decides.
The rules, in full:
- Every object with static storage duration is zero-initialized first, before any code runs (Chapter 3's zero-init, doing quiet work here).
- Then dynamic initialization — the constructors — runs. Within a translation unit: top to bottom, in declaration order. Across translation units: no order at all. Whatever your linker did, that is the order, and relinking can change it. This is the static initialization order fiasco, and it has carried that name for decades.
- Destruction is the exact reverse of construction. Which sounds like a guarantee, and within one TU it is — but across TUs it inherits the same coin flip. Whoever constructed last dies first.
Now the ticket assembles itself. ~Auditor needs g_logger alive, so the
logger must die later, so the logger must have constructed earlier — and
nothing anywhere enforces that. One link order satisfies it by luck; the
other builds a program that writes 21 bytes into freed memory every time it
exits. And the plain build's silence is Chapter 3's lesson wearing a new
coat: the freed block is usually still mapped, so the write lands quietly —
here. On the customer's machine, with the customer's allocator, it is a
segmentation fault in __cxa_finalize. Undefined behavior does not owe you
the same symptom twice, and it certainly does not owe you one on the
machine you debug on. Chapter 27's diamond was decided by link order too —
this is the same silent coin, flipped at exit.
The fix¶
The global moves behind a function, and the function's local static is the logger:
Logger& TheLogger() {
static Logger logger; // constructed the first time anyone asks
return logger;
}
logger.h now ends by declaring the accessor instead of the object:
A function-local static constructs the first time control passes over it — lazily, like the C# static constructor you never had to think about, and since C++11 thread-safely too. It cannot be used before it exists, by construction: the only way to reach it is through the function that creates it. That kills the startup half of the fiasco outright. Chapter 28's test registry already used this shape — its comment "a namespace-scope vector would be a bet on initialization order" was this chapter, in eight words.
The exit half needs one more move, and it is the load-bearing one:
#include "logger.h"
class Auditor {
public:
Auditor() { TheLogger().write("audit: session opened"); } // pins the order
~Auditor() { TheLogger().write("audit: session closed"); }
};
Auditor g_auditor;
The constructor's call is not logging for logging's sake. First use
constructs the logger, so the logger finishes constructing before
g_auditor does — and reverse-order destruction, the same rule that broke
the program, now guarantees the logger outlives the auditor. Touch your
dependencies in your constructor: it turns destruction order from a bet
into a consequence.
The acceptance test is the one from the ticket card: both link orders,
clean. This repository runs exactly that on every push —
build_all.sh builds exercises/exitlab/ twice with the translation units
reversed and runs both, because the fix's whole claim is that the order
stopped mattering, and one build cannot prove a claim about two.
The same fiasco runs forwards¶
Make the auditor's constructor log too — realistic; 2.4.2 would have —
and the bad link order now fails before main: the auditor constructs
first, and g_logger.write runs on a logger that is zero-initialized but
not yet constructed. buffer_ is null (Chapter 3 again — zero-init is why
this is a null write and not garbage), and the program crashes at startup.
AddressSanitizer has a detector for exactly this, with the fiasco's name on
it:
$ ASAN_OPTIONS=check_initialization_order=1:strict_init_order=1 ./app
ERROR: AddressSanitizer: initialization-order-fiasco ...
SUMMARY: ... logger.cpp:14 in Logger::write(char const*)
One habit covers both directions: a crash before main or after it puts
namespace-scope constructors at the top of the suspect list. grep for
globals with constructors; the list is usually short and one of them is
usually your bug.
Pitfalls¶
- A clean sanitizer run proved less than you think — again. The first
draft of this lab used
std::vector<std::string>as the log. The exit write went into freed memory and ASan said nothing: libc++'s container annotations un-poison the slot onpush_backbefore constructing into it (observed on macOS/AppleClang — the freed block reads as poisoned before the call and clean after). Finding 10 of Chapter 25 keeps collecting examples. - The local static alone is half a fix. Without the constructor touch, the logger's construction order — and so its destruction slot — is still set by whoever happens to call first. Legal, and back to gambling.
- The immortal variant. For something written to by everyone's
destructors, the honest shape is
static Logger* logger = new Logger;— constructed on first use, never destroyed, so there is no destruction slot to lose. The block stays reachable, so LeakSanitizer's default stays quiet about it. Deliberate immortality is a tool; accidental immortality is a leak — write the comment. - Plug-ins pay double. A shared library's globals construct at load and
destroy at unload, on the host's schedule (Chapter 30's boundary rules
exist partly for this). If your plug-in needs setup and teardown, want
them to be entry points the host calls — the
X_Init/X_DeInitpair of Chapter 16's Bestiary — not side effects of the loader.
Key principle: "A namespace-scope object with a constructor is a bet on link order. I construct on first use, and I touch my dependencies in my constructor — so teardown unwinds in the order I chose, not the order the linker did."
In the wild¶
The fiasco is old enough that the ecosystem is full of armour against it,
once you know what you are looking at. std::cout is safe to use from
another TU's constructor only because <iostream> plants a small counter
object in every file that includes it, forcing the stream's setup ahead of
yours. Qt ships Q_GLOBAL_STATIC — construct-on-first-use as a macro. And
Chapter 16's Shape 4 warned that embedded HALs treat some resources as
"singletons whose ownership is initialization order" — the vendors'
X_Init/X_DeInit pairs are this chapter's lesson, shipped as API:
initialization order made explicit, because the implicit kind cannot be
trusted. When you author your own boundary (Chapter 30), do the same.