Skip to content

Case 102: Frozen Runtime Signature Changed

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

Category: Runtime contract | Verdict: โŒ BREAKING

Verdict and consumer impact

mylib::detail::r1::dispatch is an extern "C" runtime entry point inside a namespace the library documents as contractually frozen (mirroring oneTBB's detail::r1 โ€” see References). v2 widens its parameter and return type from int to long in place, instead of adding a new entry point. The exported symbol name (dispatch) is unchanged, so the binary still links โ€” but every consumer compiled against v1 pushes an int argument into what is now a long parameter slot, and reads the return value back as an int truncation of a long. Recompilation is mandatory even though nothing failed to link.

Old/new diff

v1.h v2.h
extern "C" int dispatch(int concurrency); extern "C" long dispatch(long concurrency);

abicheck command

clang++ -std=c++17 -shared -fPIC -g v1.cpp -o libmylib_v1.so
clang++ -std=c++17 -shared -fPIC -g v2.cpp -o libmylib_v2.so
abicheck compare libmylib_v1.so libmylib_v2.so

Expected abicheck finding

Verdict: BREAKING (exit 4)

- func_return_changed: Return type changed: dispatch (int -> long)
  > Callers expect the old return type layout in registers/stack;
    misinterpretation causes data corruption.
- func_params_changed: Parameters changed: dispatch (int -> long)
  > Callers push arguments with the old layout; callee reads wrong data
    from stack/registers.

Minimum evidence

min_evidence: L1 โ€” DWARF's DW_TAG_formal_parameter/return-type entries carry the widened signature for both versions; the exported symbol name alone doesn't change (extern "C" leaves it un-mangled), so the signature comparison, not the symbol table, is what surfaces the break. No public headers are required for this base finding.

Why abicheck catches it

DWARF records dispatch's parameter and return types for both versions; abicheck diffs them directly, the same mechanism as case 02, and reports func_params_changed/func_return_changed โ€” both already BREAKING under strict_abi on their own, with no new ChangeKind needed for this case.

On the frozen_namespaces: policy layer: this case directory also ships policy.yaml, which declares **::detail::r1::* (and r2) as a contractually frozen namespace. Passing --policy-file policy.yaml reproduces the identical BREAKING verdict and finding set above (verified directly) โ€” the policy's purpose (per its own source in abicheck/post_processing.py's EscalateFrozenNamespaceViolations) is to tag any matching finding with Change.frozen_namespace_violation and refuse a policy overrides: entry that would downgrade it, so a reviewer can't accidentally silence an r1 contract violation. Recovering the qualified name to match the glob pattern relies on demangling the linker symbol or on header/source-graph evidence; because dispatch is extern "C" its exported symbol carries no namespace information at all (that's what extern "C" means), so in this reproduction โ€” with or without --header/--policy-file โ€” the tag did not attach to the func_params_changed/func_return_changed findings themselves. The underlying BREAKING verdict is unaffected either way; treat the escalation tag as the intended design for namespaces recoverable from available evidence, not as something this specific extern-"C" fixture was observed to trigger here.

Runtime failure demonstration

Severity: documented as CRITICAL by the fixture; not reproduced as a visible crash here.

Scenario: compile app against v1, swap in v2 .so without recompile.

ln -sf libmylib_v1.so libmylib.so
clang++ -std=c++17 -I. app.cpp -L. -lmylib -Wl,-rpath,. -o app
./app
# โ†’ run(7) = 14 (expect 14)

# Swap in new library (no recompile)
ln -sf libmylib_v2.so libmylib.so
./app
# โ†’ run(7) = 14 (expect 14)   -- NOT visibly corrupted in this run

Tried directly: on this toolchain/architecture (x86-64, clang -O0/-O2), the compiler zero/sign-extends the int argument into the full 64-bit register at the call site as an artifact of normal code generation, so the upper 32 bits dispatch's v2 body reads happen to already be zero and no visible corruption occurs for this specific small input. That's exactly why this class of break is dangerous: nothing in the ABI guarantees the upper bits are clean (a caller whose register history leaves garbage there โ€” e.g. after other 64-bit arithmetic โ€” would see real corruption), so relying on today's "it happened to work" is not a substitute for matching the signature. The mandatory-recompilation verdict does not depend on reproducing a crash.

Safe redesign

Don't edit detail::r1::* in place. Either:

  1. Add to r2: introduce detail::r2::dispatch(long) as a new exported symbol; keep detail::r1::dispatch(int) alive forever as a thin shim that calls into the r2 entry.
  2. Wrap, don't rename: provide an overload at the public surface (mylib::run(long)) that dispatches to the new entry; the old inline run(int) keeps calling the old r1::dispatch(int).

Real-world example: oneTBB's VERSIONING.md specifies that the runtime-symbol namespace tbb::detail::r1 is append-only: existing entry points are frozen at their shipped signature forever, and any incompatible change must land in a new namespace (r2) instead.

Cross-tool comparison

abidiff (binary-only, DWARF-based) would see the same parameter/return type widening and report it as an ABI change; it has no concept of a policy-declared "frozen namespace" or a downgrade guard โ€” that's unique to abicheck's policy-file layer. Not re-verified numerically in this environment (abidiff is not installed here).

References


Source files

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

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