Skip to content

Case 15: noexcept Removed

Field Value
Verdict ๐ŸŸก COMPATIBLE_WITH_RISK
Category Risk
Platforms Linux
Flags API break
Detected ChangeKinds runtime_floor_raised
Source files examples/case15_noexcept_change/

Category: Risk | Verdict: ๐ŸŸก COMPATIBLE_WITH_RISK

Verdict and consumer impact

reset() keeps its mangled symbol name in both versions (Itanium C++ ABI does not fold noexcept into function-symbol mangling), so existing binaries still resolve the call โ€” this is not a linkage break. What changes is the contract: v2's implementation now throws, which pulls in __cxa_throw and std::runtime_error, and that raises the library's minimum required libstdc++.so.6 version from GLIBCXX_3.4 to GLIBCXX_3.4.21. Deploying v2 onto a system whose libstdc++ predates that version means the .so fails to load at all. Separately โ€” and outside what a binary-ABI tool can see โ€” code compiled against v1's noexcept guarantee may have omitted exception landing pads; if v2's reset() throws at runtime, the exception escapes a noexcept frame and the process calls std::terminate.

Old/new diff

v1.h v2.h
void reset() noexcept; void reset();
-void reset() noexcept;   // v1: no-throw implementation
+void reset();             // v2: throws std::runtime_error

abicheck command

g++ -shared -fPIC -std=c++17 -g v1.cpp -o libv1.so
g++ -shared -fPIC -std=c++17 -g v2.cpp -o libv2.so
abicheck compare libv1.so libv2.so

Expected abicheck finding

Verdict: COMPATIBLE_WITH_RISK (exit 0)

Deployment Risk Changes:
- symbol_version_required_added: New symbol version requirement:
  GLIBCXX_3.4.21 (from libstdc++.so.6)
- runtime_floor_raised: Runtime floor raised for libstdc++.so.6:
  GLIBCXX_3.4 -> GLIBCXX_3.4.21
  (required by: std::runtime_error::runtime_error(char const*)@GLIBCXX_3.4.21)
- imported_symbol_added (x6): new imports pulled in by the throw path
  (__cxa_throw, __cxa_allocate_exception, std::runtime_error ctor/dtor, ...)

Minimum evidence

min_evidence: L0 โ€” the ELF dynamic section's version-requirement table (VERNEED) alone is enough: v2's .so records a dependency on GLIBCXX_3.4.21 that v1 doesn't have, because linking __cxa_throw / std::runtime_error pulls in symbols versioned against that release. No DWARF or headers are needed to see the new runtime floor โ€” noexcept itself, notably, is not observable at this evidence level at all (it isn't in DWARF or the symbol table); this run reports the deployment-risk side of the change, not the noexcept removal itself.

Why abicheck catches it

abicheck parses each .so's ELF VERNEED/VERDEF sections and compares the two versions' minimum-required-symbol-version sets per imported library. When v2 references a versioned symbol that requires a newer GLIBCXX_x.y.z than anything v1 required, abicheck flags a runtime-floor increase โ€” a real deployment constraint even though the library's own exported ABI is unchanged.

Runtime failure demonstration

Severity: CRITICAL (behavioral, not linkage)

Scenario: app compiled against v1 (reset() declared noexcept, so the compiler omits landing pads) calls v2's reset(), which throws.

# Build v1 + app (app includes v1.h, which declares reset() noexcept)
g++ -shared -fPIC -std=c++17 -g v1.cpp -o libbuf.so
g++ -std=c++17 -g app.cpp -I. -L. -lbuf -Wl,-rpath,. -o app
./app
# โ†’ Calling reset()...
# โ†’ reset() completed OK

# Swap in v2 (reset() now throws)
g++ -shared -fPIC -std=c++17 -g v2.cpp -o libbuf.so
./app
# โ†’ terminate called after throwing an instance of 'std::runtime_error'
# โ†’   what():  reset failed
# โ†’ Aborted (core dumped)

Why CRITICAL: the caller was compiled trusting the noexcept guarantee, so no exception-handling frame was generated for that call. When v2 throws, std::terminate fires unconditionally โ€” no catch clause anywhere in the call stack can intercept it. Binary linkage is fine (the symbol resolves); the crash is a source-level contract violation abicheck compare cannot see from the binaries alone, which is exactly why the verdict is COMPATIBLE_WITH_RISK rather than BREAKING.

Safe redesign

Never remove noexcept from a function whose callers may already assume the no-throw guarantee โ€” treat it the same as a signature change and introduce a new, differently-named entry point instead. If a throwing implementation is unavoidable, bump the SONAME so the version requirement change is explicit rather than discovered at load time.

Real-world example: in Facebook's Folly library, several internal reset()/destroy() methods had noexcept removed during a refactor. Downstream projects compiled against the old headers started hitting silent std::terminate crashes when running against the new .so.

Cross-tool comparison

abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml
echo "exit: $?"   # โ†’ 0 โ€” abidiff misses this change entirely

Why abidiff misses it: abidiff compares DWARF type information and symbol tables. noexcept is not stored in DWARF โ€” it's a purely source-level annotation โ€” and abidiff does not inspect VERNEED/runtime version requirements either, so it reports no difference at all.

Why ABICC catches part of it: abi-compliance-checker parses C++ headers directly (via GCC internals) and sees the noexcept specifier difference between v1.h and v2.h, flagging it as a source-level compatibility break โ€” but from headers alone it has no visibility into the GLIBCXX runtime-floor risk that only shows up once the throwing implementation is linked into the .so.

References


Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.