assert() is a no-op (sometimes)

I once worked on a project that made liberal use of assertions to enforce invariants. This was useful during development, as one could easily see whether you broke something when you ran the test application locally, since it would crash. Of course, these were no replacement for unit and integration testing, but they served to provide an additional level of sanity checking.

One day, someone from the QA team came up to me and said that they were seeing this particular bug manifest, but it was only happening in our Release Builds, and not in the Debug Builds that developers typically used.

For this project, a Release Build meant compiling with -O3, LTO, stripping symbols, disabling a bunch of error checking and logging, and so on. There were many possible things that could result in a discrepancy between a Debug and Release Build.

I realized that the only way this bug could manifest is if a cleanup function was never getting called. But shouldn't the function always be called, considering it was working on the Debug builds?

The problem immediately became obvious when I looked at the code.

During some refactoring, someone had overzealously added an assertion that a function call would return a particular result. This would have been fine, had they written it something like this:

#include <assert.h>

int err = 0;
err = foo();

assert(err == 0);

Unfortunately, they were performing the check on the same line.

assert(foo() == 0);

If we check the man pages for assert, we see:

If the macro NDEBUG is defined at the moment <assert.h> was last included, the macro assert() generates no code, and hence does nothing at all.

Essentially, the function call was never even occurring, since our Release Builds were setting NDEBUG!

If we look at the code for assert, it looks something like this:

#ifdef NDEBUG           /* required by ANSI standard */
# define assert(__e) ((void)0)
#else
# define assert(__e) ((__e) ? (void)0 : __assert_func (__FILE__, __LINE__, \
                               __ASSERT_FUNC, #__e))

The version in glibc is a bit more complicated to follow, so I've included the snippet from newlib. However, note that the behaviour of assert and NDEBUG is required by the ANSI C standard.

Here's some sample code to illustrate this behaviour:

#include <assert.h>
#include <stdio.h>

int main(int argc, char *argv[]) {
  assert(0 == 1);
  printf("0 == 1\n");

  return 0;
}

Now, obviously we would expect the program to crash, since 0 is most definitely not equal to 1. And compiling it normally gives us the expected result

 → TITANIC@~ $ gcc test.c -o test && ./test
test: test.c:5: main: Assertion `0 == 1' failed.
Aborted (core dumped)

If we set NDEBUG:

 → TITANIC@~ $ gcc -DNDEBUG test.c -o test && ./test
0 == 1

You can verify this for yourself by comparing the disassembly between the two versions—you'll see that the function call is never generated when NDEBUG is defined.

So what's the moral of this story?

  1. Don't assert on a condition with side effects. Save the result and then assert on it.
  2. Make sure you know what compiler options you're using for a particular build (or at least, know how to figure that out). Certain build systems will set NDEBUG implicitly, depending on what other flags are set. For example, CMake will define NDEBUG if CMAKE_BUILD_TYPE=Release is set.
  3. Take advantage of static analysis tools. clang-tidy has bugprone-assert-side-effect, which attempts to detect if a statement in an assert could cause side effects.