Skip to content

Case 15: noexcept Removed

Field Value
Verdict 🟡 COMPATIBLE_WITH_RISK
Category Risk
Classification Rule
Platforms Linux
Flags API break
Detected ChangeKinds runtime_floor_raised
Source files catalog/cases/case15_noexcept_change/
Rule family noexcept-change
Subject Build-flag and toolchain-mode drift

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 the cleanup landing pad for that call) calls v2's reset(), which throws.

app.cpp deliberately wraps the call in a try/catch and puts a destructor-bearing Sentinel in scope. Both are load-bearing: without them the program would abort on any uncaught exception, which would prove nothing about noexcept. With them, the two outcomes are distinguishable — a handler that runs and a destructor that fires is an ordinary exception; neither happening is the contract violation.

# 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
# → SENTINEL DESTRUCTOR RAN
# → main returning normally

# 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)

Note what is absent from the second run: no CAUGHT: line and no SENTINEL DESTRUCTOR RAN. The handler that plainly exists in the source never runs, and neither does the local destructor.

Why CRITICAL: the caller was compiled trusting the noexcept guarantee, so no cleanup landing pad was generated for that call. When v2 throws, the exception travels through a frame that was never compiled to cooperate with it — in the run above (GCC, x86-64) that ends in std::terminate, with a present-in-source catch bypassed and a local destructor skipped. Treat the outcome as toolchain- and control-flow-dependent rather than fixed; what is guaranteed is that the caller's no-throw assumption no longer holds. See Exception Unwinding for the machinery. 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: Compatibility Catalog · All COMPATIBLE_WITH_RISK cases · Category: Risk · Rule: noexcept specification changed · Subject: Build-flag and toolchain-mode drift.