Module 13 — Debugging¶
The technique this module adds¶
Failure-path forensics: believe nothing about the failure path that you have not run, and make the failure happen on purpose.
Every earlier module's technique was applied to code that ran. This module's failures are the ones that do not run reliably — an abort with a message that names the wrong thing, a leak that appears only when a file is missing, a container half-updated by an operation that threw. There are four moves, and you will use all of them.
Move 1 — read the termination message correctly¶
A terminated program prints something like this, and nothing else:
terminate called after throwing an instance of 'std::runtime_error'
what(): cannot close the catalogue file
Two traps, both of which cost people hours:
The message may name the wrong failure. In the reproduction from the
worked examples, the
exception that was actually propagating was the original failure. The message reports
cannot close the catalogue file, because a destructor threw while the first exception was still in
flight — and the first exception is, by definition, gone. If the message contains a phrase you do not
recognise, look for a destructor that throws before you look anywhere else.
Nothing you printed may appear at all. std::cout is buffered, and std::terminate does not
unwind, flush, or run anything on the way out. Anything still in the buffer is discarded with the
process:
std::cout << " [cleanup] " << name << "\n"; // lost: still buffered when the process aborts
std::cout << " [cleanup] " << name << std::endl; // survives: endl flushed it
std::cerr << " [cleanup] " << name << "\n"; // survives: cerr is unbuffered
Diagnostics belong on std::cerr for exactly this reason. If you have already printed your trace with
std::cout, adding std::flush at the point where the problem occurs is usually enough to see how
far the program got.
Move 2 — stop at each throw, and read the frames¶
When the message names the wrong failure, you want the whole sequence rather than the last line. A
debugger catchpoint on every throw gives you exactly that. gdb is available with this toolchain:
Catchpoint 1 (throw)
[New Thread 15908.0x4238]
[New Thread 15908.0x1cbc]
Thread 1 hit Catchpoint 1 (exception thrown), 0x00007ff6af0cb527 in __cxa_throw ()
#0 0x00007ff6af0cb527 in __cxa_throw ()
#1 0x00007ff6af0014cd in main ()
Thread 1 hit Catchpoint 1 (exception thrown), 0x00007ff6af0cb527 in __cxa_throw ()
#0 0x00007ff6af0cb527 in __cxa_throw ()
#1 0x00007ff6af0201d8 in Loader::~Loader() ()
#2 0x00007ff6af00150d in main ()
The addresses change on every build; the frames are the information, and they are named — which is
precisely what the terminate message never gives you. Interactively the same recipe is catch throw,
run, bt, continue, bt.
Read the sequence, not each stop. The first throw is the real failure (main), and the second —
from Loader::~Loader() — is the one that killed the program. The same two commands answer "who
threw this?" for a legitimate exception you do not understand, without rebuilding anything.
The same recipe works for a legitimate throw that you simply do not understand yet. catch throw
plus bt answers "who threw this?" in two commands, and it is the fastest way to find out which of
your three throw statements actually fired.
Move 3 — keep a balance sheet¶
Counters turn "was that released?" from an argument into arithmetic. This is the module's first-line technique, and it needs no tools at all:
struct Book {
static int acquired;
static int released;
Book() { ++acquired; std::cerr << "[Book] +" << acquired << '\n'; }
~Book() { ++released; std::cerr << "[Book] -" << released << '\n'; }
};
Then print the pair at the end of the run, on both paths:
acquisitions 7
refused 1 (the acquisition threw before a resource existed)
releases 5
devices still live 2
Two rules make the book-keeping trustworthy:
- Every acquisition has a matching release, or the difference is the bug. A non-zero balance at the end of either run is a leak, whichever path produced it.
- Run the success path and the failure path. A balance sheet from the success run proves nothing about the failure run — that is where the leak lives.
examples/module-13/02-unwinding-and-cleanup/main.cpp is built entirely out of this instrument, and
its final book-keeping is the diagnosis: two devices still live, each from a different mistake, both
of which the counter refuses to let you ignore.
Move 4 — make the failure happen on purpose¶
A failure path that has never run is not a failure path; it is a hope. So the failure gets triggered, deliberately, from the exact line you want to test:
void loadItems(const std::string& path) {
...
if (failAfter == ++calls) { // a switch your test can set
throw std::runtime_error("simulated read failure");
}
}
Then, in a test or a demonstration main:
catalogue.add("Compilers", 2);
const std::size_t before = catalogue.size();
failNextRead = true;
try {
catalogue.importFrom("catalogue.txt");
} catch (const std::exception& error) {
std::cerr << "expected failure: " << error.what() << '\n';
}
assert(catalogue.size() == before); // the strong guarantee, checked
That assert is the whole point: the claim "this operation is strongly exception-safe" is only worth
something if the failure has actually been produced and the state inspected afterwards. Do not wait
for the disk to fill or the file to be deleted — make the throw happen.
A top-level handler is part of the same move. It converts an abort into a readable message and a clean exit status, and it is the difference between a stack trace and a mystery:
int main() {
try {
return run();
} catch (const std::exception& error) {
std::cerr << "failed: " << error.what() << '\n';
return 1;
}
}
Catching everything here is legitimate — it is a boundary, not a place where failures disappear — and
it is where catch (...) earns its keep as a last resort that reports and rethrows.
Why "it did not leak in my test" is not evidence¶
Leaks and corrupted state live on the path that did not run. A hundred successful runs tell you nothing about the one failure you have not caused; and a leak that needs an exception to appear is invisible to every test that never throws.
The three questions to ask instead:
- Which paths exist? Success, early return, throw, and the constructor that threw half-way.
- Which of them has this code actually taken? Name the run.
- What is the balance on that run? Counters, not impressions.
If you cannot name the run, the answer is "none of them has been tested yet", and the fix is Move 4.
A worked diagnosis¶
Symptom. A catalogue reloads its items from a file. Two things are wrong, and only one of them appears on the normal run:
- when the file is fine, the program is correct;
- when the file is missing — or the seventh item is malformed — the program reports the failure
correctly, and one device stays open.
Part Bof the example program reproduces exactly this, and its closing book-keeping counts it:devices still live 2, one from this mistake and one from the constructor case in Step 5.
Step 1 — reproduce it deliberately. Do not wait for the file to vanish; make the read fail:
Step 2 — put a counter on the resource. One acquisition line, one release line, each printing to
std::cerr. The imbalance is now a number: acquired 1, released 0.
Step 3 — ask where the release was supposed to happen. It was a statement:
void reload(const std::string& path) {
HANDLE file = openCatalogue(path);
load(file); // throws here
close(file); // never reached
}
The statement is correct. It was skipped — and this is the important part of the diagnosis: the handle is a local variable, so when unwinding destroys the frame the handle is gone and nothing can release it later. The bug is not the missing cleanup; it is that no object owned the resource.
Step 4 — change the ownership, not the flow. Replace the handle with a type whose destructor
releases it, and delete the close(file) line. The counter now reads acquired 1, released 1 on all
three paths — success, early return, and throw — with nothing added to the throwing path.
Step 5 — check the constructor case too, because it is the same bug wearing a different hat. A resource acquired in a constructor body is not owned when the body throws:
Register(const std::string& path) : first_(path) { // first_ is a member: destroyed if this throws
device_ = acquire(path); // a raw member pointer: NOT released
if (...) { throw std::runtime_error("construction failed"); }
}
The member was destroyed; the raw handle was not. Same fix, one line moved.
What the diagnosis was really about. Nothing in this worked example required reading a message carefully or knowing a compiler flag. It required a counter, a deliberate throw, and the realisation that only a destructor is called on every path. That is what makes this module's debugging different from the others: the instrument is your own object's lifecycle.
The escalation ladder for this module¶
The course's general ladder, specialised for failure paths:
- Read the message — and distrust it, because a destructor's throw hides the original failure.
- Print the state — with
std::cerr, and with counters on both paths. - Reduce it — the failure in fifteen lines: one object, one throw, no files, no user input.
- Test the claim — throw deliberately, then assert the state and the balance.
- Ask the machine —
catch throwwithbtfor the throw path; the counters for the balance.
Note what is not on this list for this toolchain: AddressSanitizer. On this machine it is not available, and the course says so rather than pretending otherwise. Counting is the fallback, and it is enough for the failures this module creates, because they are all balance-sheet failures — see Common OOP Bugs for the counting technique in full.
Checklist for any failure path you write¶
- [ ] Every operation's guarantee (nothrow / strong / basic) is written next to its declaration
- [ ] No
catchblock releases a resource — ownership is in types - [ ] Destructors cannot throw; the ones that call something fallible say what happens instead
- [ ] Resources are acquired into members, never into a constructor body
- [ ] The failure has been produced on purpose, at a known line
- [ ] After the failure, the invariants have been checked, not assumed
- [ ] The counters balance on the successful run and the failing run
- [ ] Diagnostics go to
std::cerr, so an abort cannot swallow them
Continue¶
Previous: Misconceptions and mistakes · Next: Exercises · Module home: Module 13