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_baseknn_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.txtapp.cppv1.cppv1.hv2.cppv2.h
See also: Compatibility Catalog · All BREAKING cases · Category: Breaking · Rule: Internal templated base leaks · Subject: Leaked internal types.