Case 192: Call-Graph-Reachable Break Survives Suppression¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux, macOS, Windows |
| Flags | ABI break |
Detected ChangeKinds |
func_removed, internal_symbol_required_by_public_api |
| Source files | examples/case192_call_graph_break_survives_suppression/ |
Category: Breaking (Source Graph / Suppression) | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
demo::compute() is a public, inline dispatcher โ its body, including the
call it makes, is compiled directly into every consumer's own binary:
namespace demo {
inline Result compute(const Descriptor& d) {
return detail::compute_avx2(d); // dispatches to an internal specialization
}
}
detail::compute_avx2 is removed in v2. Any consumer binary already built
against v1 has compute()'s old body โ including the direct call to the now
-missing symbol โ baked in, so it fails to resolve at load time. A project
maintaining a blanket suppression rule for namespace: "demo::detail::**"
(a reasonable-looking rule, since nothing in detail is documented API)
would expect this removal to be silently absorbed. It is not: abicheck
proves the internal symbol is public-reachable through the call graph and
refuses to apply the broad rule without an explicit override.
Old/new diff¶
| v1 (conceptual) | v2 (conceptual) |
|---|---|
inline Result demo::compute(...) { return detail::compute_avx2(d); } |
detail::compute_avx2 (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 โ reproducing it for real would require a genuine build with
--sources/--build-info evidence. See
scripts/gen_reachability_examples.py.
abicheck command¶
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- func_removed: Public function removed: demo::detail::compute_avx2
> Old binaries call a symbol that no longer exists; dynamic linker
will refuse to load or crash at call site.
- internal_symbol_required_by_public_api: Internal symbol
'_ZN4demo6detail13compute_avx2ERKNS_10DescriptorE' changed (func_removed)
and is called/referenced from the public ABI surface. Call/reference
paths: demo::compute --[DECL_CALLS_DECL]--> demo::detail::compute_avx2.
Applying the broad suppression rule shows the reachability proof in action:
Verdict: BREAKING (exit 4) # unchanged โ the rule is refused
- suppression_would_hide_public_break: Suppression rule 'demo::detail::**'
matched '_ZN4demo6detail13compute_avx2ERKNS_10DescriptorE' (func_removed)
but was not applied: the symbol is public-reachable via
demo::compute --[DECL_CALLS_DECL]--> demo::detail::compute_avx2. Add
`allow_public_break: true` to this rule to suppress it anyway.
Only an explicit allow_public_break: true acknowledgment applies the rule:
Minimum evidence¶
min_evidence: L5 โ the raw func_removed is L0-detectable on its own, but
proving that the removed internal symbol is public-reachable (and
therefore that a blanket internal-namespace suppression rule must not
silently absorb it) requires the L5 call graph's DECL_CALLS_DECL edge from
compute()'s node, tagged consumer_compiled_body: true.
Why abicheck catches it¶
abicheck's L5 call-graph walk seeds itself from public entries whose own
body is compiled into consumer code (inline/template functions). Because
compute() is inline, its node carries consumer_compiled_body: true,
and the walk finds a DECL_CALLS_DECL edge straight to
detail::compute_avx2. When a suppression rule would hide a change to a
symbol that walk proved is public-reachable, abicheck raises
suppression_would_hide_public_break instead of silently applying the rule
(ADR-044).
Runtime failure demonstration¶
There's no app.c here โ proving this for real needs a genuine build with
--sources/--build-info evidence, which is what the committed
AbiSnapshot's embedded L5 graph stands in for. The real-world scenario is
a maintainer of a library with a detail:: namespace and a standing
suppress: {namespace: "demo::detail::**"} policy: a routine internal
refactor deletes compute_avx2, CI runs abicheck with that suppression
file, and instead of a silently green build, the CI job fails loudly with
the exact call path that makes this an oneDAL-style consumer-visible break,
not an internal-only cleanup.
Safe redesign¶
Never remove a symbol an inline public entry point calls without a
deprecation cycle, even if the symbol's own name looks internal. Either move
the dispatch logic out-of-line behind a stable exported symbol (so future
changes are ABI events, not baked-in call sites), or review and explicitly
acknowledge the break with allow_public_break: true plus a tracking
reference, as suppress-acknowledged.yaml does here.
Real-world example: this is the headline scenario ADR-044 was built for
โ oneDAL-style dispatchers that inline-forward to internal
architecture-specific specializations (compute_avx2, compute_sse42,
...), where a specialization only "looks" internal by namespace convention.
Cross-tool comparison¶
abidiff/abi-compliance-checker operate on compiled binaries and debug
info; neither has an equivalent to abicheck's L5 call-graph walk or its
reachability-aware suppression engine, so neither can distinguish this case
from an ordinary internal-symbol removal, let alone refuse a suppression
rule based on proven call-graph reachability. Contrast with
case193,
where the identical suppression rule applies cleanly with no diagnostic at
all, because the calling function there is not one a consumer's own binary
compiles.
Source files¶
new.abi.jsonold.abi.jsonsuppress-acknowledged.yamlsuppress-refused.yaml
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.