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.
noexceptis not stored in DWARF โ it's a purely source-level annotation โ and abidiff does not inspectVERNEED/runtime version requirements either, so it reports no difference at all.Why ABICC catches part of it:
abi-compliance-checkerparses C++ headers directly (via GCC internals) and sees thenoexceptspecifier difference betweenv1.handv2.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¶
- C++
noexceptspecifier - P0012R1:
noexceptas part of the function type checker_policy.pyโRISK_KINDS
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All COMPATIBLE_WITH_RISK cases ยท Category: Risk.