Case 193: Ordinary Exported Function's Internal Call Is Not Public-Reachable¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break |
Detected ChangeKinds |
func_removed |
| Source files | examples/case193_ordinary_exported_fn_call_not_reachable/ |
Category: Breaking (Source Graph / Suppression) | Verdict: ๐ด BREAKING (without suppression)
Verdict and consumer impact¶
demo::api() is an ordinary, out-of-line exported function โ defined in a
.cpp file, not inline, not a template:
// api.cpp โ compiled into libdemo.so only, never into any consumer's binary
void demo::api() {
detail::log_context(); // internal call, resolved entirely inside libdemo.so
// ...
}
detail::log_context() is removed in v2. This looks structurally identical
to case192 โ a
public function calling into a detail:: decl that gets removed โ but the
outcome is the opposite: api()'s body is compiled into libdemo.so's own
binary only, so a consumer linking against api()'s exported symbol never
sees, references, or embeds detail::log_context(). The raw
func_removed still makes this release BREAKING on its own (it is a genuine
public-symbol removal on the ABI surface), but a blanket internal-namespace
suppression rule applies cleanly to it with no reachability diagnostic โ
exactly as it should, since no consumer's binary calls the missing symbol.
Old/new diff¶
| v1 (conceptual) | v2 (conceptual) |
|---|---|
void demo::api() { detail::log_context(); ... } |
detail::log_context (removed) |
This case ships committed AbiSnapshot fixtures (old.abi.json /
new.abi.json) with an embedded L5 call graph instead of a compilable
v1/v2 pair. See
scripts/gen_reachability_examples.py.
abicheck command¶
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- func_removed: Public function removed: demo::detail::log_context
> Old binaries call a symbol that no longer exists; dynamic linker
will refuse to load or crash at call site.
Note what's absent: no internal_symbol_required_by_public_api overlay,
unlike case192 โ the call-graph walk never seeds from api() because its
node is not tagged consumer_compiled_body: true.
Applying the same shape of broad suppression rule shows the negative-space proof:
Minimum evidence¶
min_evidence: L0 โ func_removed alone is fully detectable from the
exported-symbol table, no debug info or graph required. The embedded L5
graph in this fixture exists only to demonstrate the negative: that the
call-graph walk does not manufacture an extra
internal_symbol_required_by_public_api overlay here, something
expected_kinds (a positive-findings list) can't encode by itself.
Why abicheck catches it¶
func_removed comes straight from diffing the two snapshots' exported-symbol
sets โ pure L0 evidence. Separately, abicheck's L5 call-graph walk only
treats a callee as public-reachable when the calling node's own body is
compiled into consumer code (consumer_compiled_body: true). api() is an
ordinary out-of-line function, so its node is tagged false; the walk never
follows its DECL_CALLS_DECL edge to detail::log_context(), and no
reachability overlay fires.
Runtime failure demonstration¶
There's no app.c here โ the point of this fixture is what a build-source
CI job sees, not a compiled crash. A consumer built against libdemo.so v1
that only calls demo::api() never touches detail::log_context()
directly: removing it either breaks libdemo.so's own build (the vendor's
problem, invisible to any consumer) or simply drops a call inside api()'s
recompiled body โ never a consumer-visible break through that path. What
is consumer-visible is the plain symbol removal via func_removed itself
if api() were the one removed instead; here it demonstrates that a CI
job diffing compile_commands.json/build evidence across releases, with a
standing internal-namespace suppression policy, correctly stays quiet about
this specific removed helper rather than raising a false reachability
alarm for the common case (an exported function calling a private one).
Safe redesign¶
No fix needed for the reachability behavior itself โ this is the intended
common-case outcome, not a defect. detail::log_context()'s removal is
still a real ABI break if it were itself part of the exported surface
(which it is here, hence func_removed); treat any exported symbol removal
with the usual deprecate-then-remove discipline regardless of whether a
call-graph overlay also fires.
Real-world example: most functions in most libraries are exactly this shape โ ordinary, exported, out-of-line, calling private helpers that never leak into a consumer's own binary. This is the deliberate negative-space counterpart to case192's positive one, keeping broad internal-namespace suppression usable for the common case instead of chasing the rare one.
Cross-tool comparison¶
abidiff/abi-compliance-checker would see the same func_removed-class
finding for the plain symbol removal (both tools detect exported-symbol
removals as their bread-and-butter case), but neither has a call-graph
reachability model to distinguish "this internal call is baked into
consumer binaries" (case192) from "this internal call never leaves the
library" (this case) โ that distinction, and the suppression-refusal
behavior it enables, is unique to abicheck's L5 evidence layer (ADR-044).
Source files¶
new.abi.jsonold.abi.jsonsuppress.yaml
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.