Skip to content

Case 77: Internal detail:: Templated Base Class Layout Change

Field Value
Verdict 🔴 BREAKING
Category Breaking
Classification Rule
Platforms Linux, macOS, Windows
Flags ABI break, API break
Detected ChangeKinds type_field_added, type_field_offset_changed, type_size_changed
Source files catalog/cases/case77_detail_templated_base_changed/
Rule family detail-templated-base-changed
Subject Leaked internal types

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

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.
- type_field_offset_changed / type_size_changed: descriptor_base<Task>'s
  layout grows for every Task the library instantiates
  (task::classification, task::regression), shifting neighbor_count_'s
  offset in every knn_descriptor<Task>.

Note: internal_template_leaks_via_public_api does not fire here. get_max_iter() (the public knn_descriptor<Task> accessor added alongside the field) only adds to descriptor_base's own instantiation set — it never removes one — and detect_internal_template_leaks only reports a removed instantiation. A purely-additive instantiation-set change (a new specialization, a new overload) was a real false positive against Intel oneDAL and must not itself be flagged breaking; the field addition's real break is caught by the type-layout kinds above instead, straight from DWARF debug info — no header AST needed. Passing headers (-H old=v1.h -H new=v2.h --config .abicheck.yml with compile.frontend: clang in .abicheck.yml) additionally surfaces internal_type_leaks_via_public_api (L2: descriptor_base is an internal-namespace type reachable from the public knn_descriptor<Task> via a nominal base-class edge), but that finding is corroborating, not required for the BREAKING verdict.

Minimum evidence

min_evidence: L1 — every kind this case is calibrated on (type_field_added/type_field_offset_changed/type_size_changed) is visible from DWARF debug info alone: the compiler emits complete, independent layout facts for each Task instantiation it actually generates, so no header AST is needed to see that descriptor_base<Task> grew.

Why abicheck catches it

DWARF debug info records each compiled descriptor_base<Task> instantiation's own complete field/size/offset layout — independent of any header AST, since debug info is emitted per compilation, not derived from source text. abicheck's binary-tier (L1) type-layout diff compares those layouts directly and finds max_iter_ added and neighbor_count_'s offset shifted for every Task the library actually instantiates (task::classification, task::regression) — the same object-layout fact a real consumer's stack frame depends on, reported without needing to resolve the templated inheritance edge from source.

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: Compatibility Catalog · All BREAKING cases · Category: Breaking · Rule: Internal templated base leaks · Subject: Leaked internal types.