Skip to content

Case 94: Empty Tag Gained State

Field Value
Verdict ๐Ÿ”ด BREAKING
Category Breaking
Platforms Linux, macOS, Windows
Flags ABI break, API break
Detected ChangeKinds type_size_changed, type_field_added_compatible
Source files examples/case94_empty_tag_gained_state/

Category: Type Layout | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

A type that was empty in v1 (sizeof == 1, per the C++ empty-class rule) is no longer empty in v2 (sizeof == 8). Any consumer compiled against v1 that passes the tag by value โ€” into a header-inline template, an algorithm overload selector, or any function taking it by value โ€” wrote a 1-byte argument into what is now an 8-byte parameter slot. The pattern mirrors tbb::auto_partitioner/tbb::simple_partitioner: empty tag types passed by value into header-only algorithm wrappers. The library author sees the tag as an implementation detail with "no public members", but its sizeof is part of the ABI because every call site serializes the value.

Old/new diff

v1.h v2.h
struct auto_partitioner {}; (sizeof == 1) struct auto_partitioner { void* affinity_state_; }; (sizeof == 8)

abicheck command

g++ -shared -fPIC -g -std=c++17 -I. v1.cpp -o libfoo_v1.so
g++ -shared -fPIC -g -std=c++17 -I. v2.cpp -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: mylib::auto_partitioner (8 -> 64 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.

Additions:
- type_field_added_compatible: Field added: mylib::auto_partitioner::affinity_state_
  > Field appended without changing existing offsets; old code works but
    won't initialize the new field.

Minimum evidence

min_evidence: L1 โ€” DWARF's struct-layout info (DW_TAG_structure_type size) is enough to detect the empty-to-stateful size growth; no public headers required. The field addition itself is correctly classified type_field_added_compatible (non-polymorphic struct, no existing offsets disturbed) โ€” a separate claim from the breaking size growth, which is carried entirely by type_size_changed.

Why abicheck catches it

DWARF records each struct's total byte size for both versions; abicheck's type_size_changed detector compares them directly. No layout inference or header parsing is needed โ€” the size is an explicit DWARF attribute on the DW_TAG_structure_type DIE.

Runtime failure demonstration

Severity: BREAKING (latent โ€” not observable in this minimal demo)

Scenario: app passes auto_partitioner{} by value into runner::run(), compiled against v1's 1-byte tag; swap in v2's 8-byte tag without recompiling the app.

# Build old library + app
g++ -shared -fPIC -g -std=c++17 -I. v1.cpp -o libfoo.so
g++ -g -std=c++17 -I. app.cpp -L. -lfoo -Wl,-rpath,. -o app
./app
# โ†’ run(7) = 14 (expect 14)

# Swap in new library (no recompile)
g++ -shared -fPIC -g -std=c++17 -I. v2.cpp -o libfoo.so
./app
# โ†’ run(7) = 14 (expect 14) โ€” unchanged; run() ignores its tag parameter

Why the demo doesn't crash: this minimal runner::run() never reads its auto_partitioner parameter, so the size mismatch has no observable effect here โ€” the risk is latent, not demonstrated by this toy consumer. tbb::affinity_partitioner is the real-world case where a partitioner tag does carry state read by the callee: passing a v1-sized argument into a v2-sized parameter slot there corrupts the following stack/register arguments the moment the callee actually reads past the old 1-byte extent.

Safe redesign

If you need to add state to a previously-empty tag: mark v1's tag deprecated, introduce a new tag type (e.g. auto_partitioner_v2), provide a v1-compatible overload that ignores the old tag and converts, and bump the SONAME on the next ABI release rather than growing the existing tag in place.

Real-world example: oneTBB's affinity_partitioner is intentionally larger than the other partitioners and is the only one that's stateful โ€” the library evolved this distinction specifically to avoid the silent-corruption pattern this case demonstrates. See oneTBB VERSIONING.md.

Cross-tool comparison

abidw --out-file v1.xml libfoo_v1.so
abidw --out-file v2.xml libfoo_v2.so
abidiff v1.xml v2.xml

Not independently re-verified in this environment (abidiff unavailable here) โ€” see case07's struct-layout case for a documented abidiff exit-code comparison on the same class of change (a plain struct size growth).


Source files

  • CMakeLists.txt
  • app.cpp
  • v1.cpp
  • v1.h
  • v2.cpp
  • v2.h

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