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:
- Add to r2: introduce
detail::r2::dispatch(long)as a new exported symbol; keepdetail::r1::dispatch(int)alive forever as a thin shim that calls into the r2 entry. - Wrap, don't rename: provide an overload at the public surface
(
mylib::run(long)) that dispatches to the new entry; the old inlinerun(int)keeps calling the oldr1::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.txtapp.cpppolicy.yamlv1.cppv1.hv2.cppv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.