Skip to content

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 a DECL_CALLS_DECL edge (a public function calling an internal one), hand-built fixture.
  • case187_public_struct_private_field_type โ€” same finding family via TYPE_HAS_FIELD_TYPE (a private field type), real compiled example.
  • case189_public_function_private_parameter_type โ€” same finding family via DECL_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.txt
  • app.cpp
  • detail_private.h
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

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