Skip to content

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:

gdb -batch -ex "catch throw" -ex run -ex bt -ex continue -ex bt ./terminate
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:

  1. Which paths exist? Success, early return, throw, and the constructor that threw half-way.
  2. Which of them has this code actually taken? Name the run.
  3. 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 B of 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:

if (simulateFailure && index == itemCount) {
    throw std::runtime_error("raw sensor read failed");
}

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:

  1. Read the message — and distrust it, because a destructor's throw hides the original failure.
  2. Print the state — with std::cerr, and with counters on both paths.
  3. Reduce it — the failure in fifteen lines: one object, one throw, no files, no user input.
  4. Test the claim — throw deliberately, then assert the state and the balance.
  5. Ask the machine — catch throw with bt for 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 catch block 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