Skip to content

Case 44: Cyclic Type Member Added

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

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

Verdict and consumer impact

Node โ€” a self-referential linked-list struct (struct Node { ...; struct Node *next; }) โ€” gains a long priority field. On 64-bit systems this consumes existing padding and grows sizeof(Node) from 16 to 24 bytes, shifting next's offset from 8 to 16. Because node_sum() takes Node by value, the struct's size and layout are part of the calling convention itself: a caller compiled against v1 pushes a 16-byte argument that v2's node_sum() reads as a 24-byte one. Recompilation is mandatory.

Old/new diff

v1.h v2.h
struct Node { int data; int flags; struct Node *next; }; struct Node { int data; int flags; long priority; struct Node *next; };
int node_sum(Node n); โ€” 16 bytes by value int node_sum(Node n); โ€” 24 bytes by value

abicheck command

gcc -shared -fPIC -g v1.c -o libfoo_v1.so
gcc -shared -fPIC -g v2.c -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- type_size_changed: Size changed: Node (128 -> 192 bits)
  > Old code allocates or copies the type with the old size; heap/stack
    corruption, out-of-bounds access.
  Affected symbols: node_create, node_free, node_sum
- type_field_offset_changed: Field offset changed: Node::next (64 -> 128 bits)
  > Old code reads/writes fields at stale offsets; silent data corruption.
  Affected symbols: node_create, node_free, node_sum

Additions:
- type_field_added_compatible: Field added: Node::priority
  > Field appended without changing existing offsets; old code works
    but won't initialize the new field.
  Affected symbols: node_create, node_free, node_sum

Minimum evidence

min_evidence: L1 โ€” DWARF's struct-layout info (DW_TAG_structure_type size + member offsets) is enough to detect the size change and the resulting offset shift of next, even though Node is self-referential; no public headers required.

Why abicheck catches it

DWARF represents a self-referential struct's next member as a pointer type pointing back at the same DW_TAG_structure_type DIE โ€” the cycle doesn't prevent DWARF (or abicheck's type-graph walk) from recording that type's own byte size and each member's offset, which is all the size/offset comparison needs; abicheck compares them directly from debug info in both versions.

Runtime failure demonstration

Severity: CRITICAL

Scenario: app builds a Node with v1's 16-byte layout, passes it by value to node_sum(), then the library is swapped for v2 without recompiling the app.

# Build old library + app
gcc -shared -fPIC -g v1.c -o libfoo.so
gcc -g app.c -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# โ†’ node_sum = 13
# โ†’ expected = 13
# โ†’ exit 0

# Swap in new library (no recompile)
gcc -shared -fPIC -g v2.c -o libfoo.so
./app
# โ†’ node_sum = 1222879984
# โ†’ expected = 13
# โ†’ CORRUPTION: Node by-value ABI changed (sizeof/layout mismatch)
# โ†’ exit 1

Why CRITICAL: the app still passes a 16-byte Node (its data, flags, and next fields, per v1's layout), but v2's node_sum() reads that same argument as a 24-byte Node โ€” treating bytes that are actually part of next's pointer value as the new priority field. The result is silently wrong arithmetic with no crash, from a struct that never even looked ABI-sensitive because of its self-referential shape.

Safe redesign

Avoid passing structs by value across a public API boundary โ€” pass a pointer instead (int node_sum(const Node *n)), where only the pointer size (stable) crosses the ABI boundary. If by-value semantics are required, hide the struct behind an opaque handle, or bump the SONAME when its layout changes.

Real-world example: linked-list/tree node structs that started small and by-value "for performance" are a classic source of this bug โ€” once the node type is used in even one by-value function signature, every field addition becomes an ABI break, not just an internal detail.

Cross-tool comparison

abidiff also catches this (Added_Non_Virtual_Member_Variable on a cyclic type):

abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml
echo "exit: $?"   # โ†’ 4 (TYPE_SIZE_CHANGED)

Source files

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

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