Skip to content

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.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.