Case 76: Internal detail:: Polymorphic Base Vtable Change¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
internal_type_leaks_via_public_api |
| Source files | examples/case76_detail_pimpl_vtable_changed/ |
Category: Internal Leak | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
The public mylib::svm_algorithm inherits from mylib::detail::algorithm_iface.
v2 inserts a new virtual progress() between run() and status() in the
"internal" base class. Every later vtable slot shifts by one. A v1-compiled
consumer calling status() on an svm_algorithm instance now dispatches
through the slot v2 occupies with progress() โ wrong return value at best,
crash at worst. No public name changed, only slot indices โ invisible to
any symbol-name-only tool.
Old/new diff¶
| v1.h | v2.h |
|---|---|
virtual ~algorithm_iface(); virtual int run() = 0; virtual int status() const = 0; |
virtual ~algorithm_iface(); virtual int run() = 0; virtual int progress() const; virtual int status() const = 0; |
| Slot | v1 | v2 |
|---|---|---|
| 0 | ~algorithm_iface() |
~algorithm_iface() |
| 1 | run() |
run() |
| 2 | status() |
progress() โ NEW |
| 3 | โ | status() |
abicheck command¶
g++ -std=c++17 -shared -fPIC -g v1.cpp -o libfoo_v1.so
g++ -std=c++17 -shared -fPIC -g v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so -H old=v1.h -H new=v2.h --ast-frontend clang
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_vtable_changed: vtable changed: svm_algorithm
(run(), status() const, ~svm_algorithm()
-> run(), progress() const, status() const, ~svm_algorithm())
> Vtable slot reordering; virtual dispatch calls wrong method.
- vtable_slot_count_changed: Vtable for 'mylib::svm_algorithm' changed size:
48 -> 56 bytes (~4 -> ~5 virtual slots)
- vtable_slot_count_changed: Vtable for 'mylib::detail::algorithm_iface'
changed size: 48 -> 56 bytes
- virtual_method_added: New virtual method added to existing class
mylib::detail::algorithm_iface: progress
> grows/relayouts the vtable, breaking derived classes and old binaries
The public svm_algorithm itself carries a type_vtable_changed finding โ
because it inherits the reshuffled base, its own effective vtable is
reported changed too, not just the "internal" base's.
Minimum evidence¶
min_evidence: L2 โ the header AST is what lets abicheck see that the
public svm_algorithm inherits detail::algorithm_iface, so a vtable
change on the base is understood to reach the public class's own dispatch
table. castxml is the documented default backend for this evidence layer;
clang (--ast-frontend clang) is a supported alternative AST frontend used
above.
Why abicheck catches it¶
DWARF/header vtable comparison sees algorithm_iface gain a virtual slot
mid-table (virtual_method_added + vtable_slot_count_changed, backed by
the _ZTV...algorithm_iface symbol growing from 48 to 56 bytes on Linux).
Because the header AST records svm_algorithm : public detail::algorithm_iface,
abicheck's inheritance-aware vtable diff also reports the change on the
derived public class's own effective vtable (type_vtable_changed on
svm_algorithm) โ the public-facing dispatch-table change is what makes
this BREAKING rather than an invisible internal edit.
Known gap on macOS / Windows: Mach-O and PE export tables carry no symbol-size field, so the ELF-only
symbol_size-based signal above doesn't apply there, and the header AST'svtable_indexis not reliably populated per virtual on those toolchains, so the structural vtable diff can collapse the reshuffle onto a single slot. Registered as aknown_gapinexamples/ground_truth.json.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile app against v1, swap in v2 .so without recompile โ
status() calls resolve to whatever now sits in that vtable slot.
# Build old library + app
g++ -std=c++17 -shared -fPIC -g v1.cpp -o libfoo.so
g++ -std=c++17 -g app.cpp -L. -lfoo -Wl,-rpath,. -o app
./app
# โ status=1 (expect 1)
# Swap in new library (no recompile)
g++ -std=c++17 -shared -fPIC -g v2.cpp -o libfoo.so
./app
# โ status=50 (expect 1)
Why CRITICAL: the app's call to a->status() still compiles to "call
whatever is in vtable slot 2" โ in v2 that slot now holds progress(),
which returns 50. The wrong method runs silently; there is no crash to
signal the bug, only a wrong answer.
Safe redesign¶
Treat detail:: polymorphic bases as a frozen ABI surface, or hide them
behind pimpl so the public class no longer inherits from anything that can
change:
class svm_algorithm {
public:
int run();
int status() const;
private:
struct impl;
impl* p_; // any virtual dispatch happens inside *p_, never through
// this class's own vtable.
};
If polymorphism must remain part of the public surface, only ever append new virtuals at the end of the vtable โ never insert mid-table โ and document the slot order as part of the binary contract.
References¶
- Itanium C++ ABI ยง2.5.3: vtable layout, slot indices, and the "appending is OK, inserting is not" rule.
- KDE Techbase, Binary Compatibility Issues With C++: section on virtual-method insertion.
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.