Case 77: Internal detail:: Templated Base Class Layout Change¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break, API break |
Detected ChangeKinds |
internal_template_leaks_via_public_api |
| Source files | examples/case77_detail_templated_base_changed/ |
Category: Internal Leak | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
detail::descriptor_base<Task> is a class template; the public
knn_descriptor<Task> inherits from it. v2 adds a field (max_iter_) to
the template โ which grows every instantiation simultaneously:
sizeof(knn_descriptor<task::classification>),
sizeof(knn_descriptor<task::regression>), and the offset of
neighbor_count_ in every one of them all shift. The consumer never even
names descriptor_base directly โ only knn_descriptor<task::classification>
โ yet the internal template change corrupts its layout. This mirrors
oneDAL's descriptor : public detail::descriptor_base<Task> pattern.
Old/new diff¶
| v1.h | v2.h |
|---|---|
template <typename Task> class descriptor_base { int class_count_; }; |
template <typename Task> class descriptor_base { int class_count_; int max_iter_; }; |
class knn_descriptor<Task> : public detail::descriptor_base<Task> { int neighbor_count_; }; |
same, but base now carries the extra field |
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_field_added: Field added: descriptor_base::max_iter_
> New field shifts subsequent fields; old code reads wrong offsets for
all fields after insertion point.
- internal_template_leaks_via_public_api: Internal-namespace function
template 'mylib::detail::descriptor_base::get_max_iter() const' has
changed instantiations: added=[
'mylib::detail::descriptor_base<mylib::task::classification>::get_max_iter() const',
'mylib::detail::descriptor_base<mylib::task::regression>::get_max_iter() const']
> These mangled names participate in consumer symbol tables; every
consumer must rebuild.
Minimum evidence¶
min_evidence: L2 โ the header AST is what lets abicheck see
knn_descriptor<Task> : public detail::descriptor_base<Task> and expand
that template relationship per instantiation (task::classification,
task::regression). 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¶
The header AST records the templated inheritance edge, so abicheck's
template-instantiation walk (distinct from the plain nominal-base walk used
for a non-template detail:: base) follows knn_descriptor<Task> into
detail::descriptor_base<Task> for each Task the library actually
instantiates. Because the base template's own layout/member-function set
changed, the internal-namespace function-template leak detector emits
internal_template_leaks_via_public_api, keyed by the fully mangled,
per-instantiation names that show up in a real consumer's symbol table โ
so the "internal" template change is reported as part of the effective
public ABI, not dismissed as private churn.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile app against v1, swap in v2 .so without recompile โ
the app's stack-allocated knn_descriptor<task::classification> is sized
for v1's smaller layout, but v2's constructor writes into the larger one.
# Build old library + app
g++ -std=c++17 -shared -fPIC -g v1.cpp -o libfoo.so
g++ -std=c++17 -g -O0 app.cpp -L. -lfoo -Wl,-rpath,. -o app
./app
# โ class_count = 2 (expect 2)
# โ neighbor_count = 5 (expect 5)
# โ factory class_count = 2 (expect 2)
# Swap in new library (no recompile)
g++ -std=c++17 -shared -fPIC -g v2.cpp -o libfoo.so
./app
# โ *** stack smashing detected ***: terminated
Why CRITICAL: the app's knn_descriptor<task::classification> d;
allocates v1's (smaller) stack slot, but v2's constructor initializes the
larger, template-grown base layout โ writing max_iter_ past the end of
the allocated object. glibc's stack-protector catches the overwritten
canary and aborts the process.
Safe redesign¶
Never add fields to a detail:: base template that a public class
inherits from. Use the opaque-pointer (pimpl) idiom instead, or freeze the
base template's layout and add new state through a separate, versioned
extension point:
template <typename Task>
class knn_descriptor {
public:
knn_descriptor();
int get_neighbor_count() const;
private:
struct impl;
impl* p_; // detail::descriptor_base<Task> lives behind this pointer
};
Real-world example: cpp/oneapi/dal/algo/knn/common.hpp declares
class descriptor : public detail::descriptor_base<Task> โ a single field
added to oneDAL's detail::descriptor_base<Task> would break the binary
layout of every shipped algorithm descriptor built on it.
Source files¶
CMakeLists.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.