Case 188: Public Class Gains a Private Base Class¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux |
| Flags | ABI break, API break, Bad practice |
Detected ChangeKinds |
type_base_changed, public_api_internal_dependency_added |
| Source files | examples/case188_public_class_private_base_class/ |
| Known kind gap | public_api_internal_dependency_added โ verdict is correct; see note below |
Category: Class Layout | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
demo::PublicHandle gains a second base class, detail::InternalBase โ
declared only in an internal, non-public header (detail_private.h, never
passed as --public-header) โ alongside its existing public Base. Adding
a base class shifts every member declared after it: PublicHandle::x moves
from offset +4 to +8, and the object grows from 8 to 12 bytes. Any binary
that allocates, copies, or accesses fields of PublicHandle is broken
without recompilation.
Old/new diff¶
| v1.h | v2.h |
|---|---|
struct PublicHandle : Base { int x; }; |
struct PublicHandle : Base, detail::InternalBase { int x; }; |
abicheck command¶
g++ -std=c++17 -shared -fPIC -g v1.cpp -o libv1.so
g++ -std=c++17 -shared -fPIC -g v2.cpp -o libv2.so
abicheck compare libv1.so libv2.so --header old=v1.h --header new=v2.h --ast-frontend clang
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- type_size_changed: Size changed: PublicHandle (64 -> 96 bits)
> Old code allocates or copies the type with the old size; heap/stack
corruption, out-of-bounds access.
- type_field_offset_changed: Field offset changed: PublicHandle::x (32 -> 64 bits)
> Old code reads/writes fields at stale offsets; silent data corruption.
- type_base_changed: Base classes changed: PublicHandle (['Base'] -> ['Base', 'detail::InternalBase'])
> Base class layout change shifts derived member offsets and vtable
pointers; this-pointer arithmetic breaks.
- struct_size_changed: Struct size changed: demo::PublicHandle (8 -> 12 bytes)
- struct_field_offset_changed: Field offset changed: demo::PublicHandle::x (+4 -> +8)
Deployment risk (binary-compatible, review needed):
- public_api_internal_dependency_added: Public entry 'use_handle' now reaches internal
declaration(s)/type(s) demo::detail::InternalBase it did not before.
Proof path: use_handle --[DECL_HAS_TYPE]--> demo::PublicHandle
--[TYPE_INHERITS]--> demo::detail::InternalBase
- public_api_internal_dependency_added: Public entry 'demo::PublicHandle' now reaches
demo::detail::InternalBase via demo::PublicHandle --[TYPE_INHERITS]--> demo::detail::InternalBase
Minimum evidence¶
min_evidence: L5 โ the layout break itself is already visible from DWARF
alone (type_base_changed/struct_size_changed/struct_field_offset_changed
all fire from -g debug info with no headers at all), so the verdict does
not strictly need L5. What L5 buys is the second finding: the source graph
is what names which internal type the public class now depends on
(public_api_internal_dependency_added, naming demo::detail::InternalBase
and the exact TYPE_INHERITS edge) โ that correlated context requires the
L2 header AST plus the L5 source-graph pass built on top of it, not DWARF
alone.
Why abicheck catches it¶
DWARF records each type's base-class list and every member's byte offset,
so the inserted InternalBase subobject and the resulting shift of x from
offset 4 to offset 8 are direct layout diffs at L1. Layered on top, the
header AST resolves detail::InternalBase as a type declared outside the
public header set, and the L5 source graph walks
use_handle --[DECL_HAS_TYPE]--> demo::PublicHandle --[TYPE_INHERITS]-->
demo::detail::InternalBase to report that the public surface took on an
undeclared dependency on internal code โ correlated context on top of the
already-detected layout break.
Runtime failure demonstration¶
Severity: CRITICAL
Scenario: compile app against v1, swap in v2 .so without recompile.
# Build old library + app
g++ -std=c++17 -shared -fPIC -g v1.cpp -o libv1.so
g++ -std=c++17 -g app.cpp -L. -lv1 -Wl,-rpath,. -I. -o app
./app
# โ exit 0 (h.x read at offset +4, matches v1 layout)
# Swap in new library (no recompile)
g++ -std=c++17 -shared -fPIC -g v2.cpp -o libv1.so
./app
# โ exit 1
Why CRITICAL: app was compiled expecting PublicHandle::x at offset
+4 (right after the single Base subobject); v2's PublicHandle inserts
InternalBase ahead of it, moving x to offset +8. The old app reads
garbage instead of 42 โ a silent field-offset corruption, not a crash.
Safe redesign¶
Either promote detail::InternalBase to a documented part of the API, or
avoid privately inheriting from internal types in a class whose layout is
part of the published ABI โ composition or a pimpl indirection instead of
inheritance for internal implementation details.
References¶
case160_public_api_internal_dep_addedโ same finding family via aDECL_CALLS_DECLedge (a public function calling an internal one), hand-built fixture.case187_public_struct_private_field_typeโ same finding family viaTYPE_HAS_FIELD_TYPE(a private field type), real compiled example.case189_public_function_private_parameter_typeโ same finding family viaDECL_HAS_TYPE(a private parameter type), real compiled example.
Ground-truth provenance¶
Known kind gap: public_api_internal_dependency_added needs --public-header set (declarations must be classified as internal); tests/validate_examples.py's default gcc/clang debug-headers lane does not set --public-header in this fixture (see tests/validate_examples.py's _kinds_strict_signal call site). The BREAKING verdict is still correct via type_base_changed alone; the L2 header-only graph is exercised for real, separately, by tests/test_header_graph_examples.py (wired into the full-matrix proof gate via the header_graph OWNER_PROOFS/SPECIAL_PROOFS entry in validation/scripts/run_example_owner_proofs.py and collect_full_example_matrix.py).
Source files¶
CMakeLists.txtapp.cppdetail_private.hv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.