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.
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: Compatibility Catalog · All COMPATIBLE_WITH_RISK cases · Category: Risk · Rule: noexcept specification changed · Subject: Build-flag and toolchain-mode drift.