Skip to content

Case 168: Virtual Method Devirtualized (flush() leaves the vtable)

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

Category: Class Layout / Vtable | Verdict: ๐Ÿ”ด BREAKING

Verdict and consumer impact

v2 removes the virtual keyword from Codec::flush() โ€” a tempting "optimization" ("nobody overrides it, and non-virtual calls are faster"). The trap: the exported symbol _ZN5Codec5flushEv survives with an identical signature, so linking and loading succeed. But flush() left the vtable, and every slot after it shifts up one position. An app compiled against v1 calls c->flush() virtually โ€” through the slot index frozen at its compile time โ€” and silently lands in reset() instead. No loader error, no crash: just the wrong method, silently, and any consumer override of flush() stops being reached at all. Recompilation is mandatory.

Old/new diff

v1.h v2.h
virtual int flush(); int flush(); /* was virtual */
vtable: [D1][D0][encode][flush][reset] vtable: [D1][D0][encode][reset]

abicheck command

g++ -shared -fPIC -g v1.cpp -o libv1.so
g++ -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_vtable_changed: vtable changed: Codec
  (~Codec, encode(int), flush(), reset() -> ~Codec, encode(int), reset())
  > Vtable slot reordering; virtual dispatch calls wrong method.
- vtable_slot_count_changed: Vtable for 'Codec' changed size: 56 -> 48 bytes
  (~5 -> ~4 virtual slots)
- func_virtual_removed: Function is no longer virtual: flush
  > Vtable entry removed; old binaries that dispatch through the vtable
    call the wrong slot.

The mangled name (_ZN5Codec5flushEv) is unchanged, so abicheck matches flush across snapshots by symbol and reports the virtuality flip directly as func_virtual_removed, rather than leaving it as an unexplained vtable size change.

Minimum evidence

min_evidence: L2 โ€” DWARF alone shows the vtable shrank and slot layout changed (type_vtable_changed, vtable_slot_count_changed), but naming which method was devirtualized needs the public header AST's virtual/non-virtual declaration for flush() on both sides โ€” DWARF's DW_AT_virtuality on the surviving flush symbol is not reliably present once it drops out of the vtable. castxml is the documented default backend for this evidence layer; clang (--ast-frontend clang, used above) is a supported alternative AST frontend for hosts without castxml installed.

Why abicheck catches it

abicheck matches flush across the two snapshots by its unchanged mangled symbol, then compares its virtuality flag from each side's header AST. Losing the virtual keyword โ€” while the symbol itself survives โ€” is reported directly as func_virtual_removed; the vtable's shrunken _ZTV5Codec size and slot-membership diff independently corroborate the same break even without headers, though at that tier they can only say the vtable changed, not name the devirtualized method.

Runtime failure demonstration

Severity: CRITICAL

Scenario: compile app against v1, link to v2 .so without recompiling.

# Build old library + app
g++ -shared -fPIC -g v1.cpp -o liblib.so
g++ -g app.cpp -L. -llib -Wl,-rpath,. -o app
./app
# โ†’ encode(21) = 42, 42 (expected 42, 42)
# โ†’ flush()    = 2 (expected 2)

# Swap in new library (no recompile)
g++ -shared -fPIC -g v2.cpp -o liblib.so
./app
# โ†’ encode(21) = 42, 42 (expected 42, 42)
# โ†’ flush()    = -1 (expected 2)
# โ†’ CORRUPTION: flush() dispatched to a different vtable slot โ€”
#   the pending count was silently discarded!

Why CRITICAL: the app's virtual call lands in reset() (returns -1, zeroes the pending counter) instead of flush(). Data is lost while every call "succeeds" โ€” no loader error, no crash, no diagnostic anywhere.

Safe redesign

  1. Don't devirtualize shipped methods. If the cost of virtual dispatch matters, add a non-virtual fast-path alongside (e.g. flush_fast()), or devirtualize internally while keeping the vtable entry as a forwarding definition.
  2. Let the compiler do it invisibly: final on the class/method enables whole-program devirtualization at call sites without changing the vtable layout โ€” though adding final is itself a source-level API event.
  3. SONAME bump if the slot must really go.

Real-world example: the KDE binary-compatibility policy lists "unoverride a virtual function" and any change to the virtual-function order among its forbidden changes: vtable slot indexes are compiled into every caller, so this is exactly as one-way as adding a new virtual method.

Cross-tool comparison

abidw --out-file v1.xml libv1.so
abidw --out-file v2.xml libv2.so
abidiff v1.xml v2.xml

References


Source files

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

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