Skip to content

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

abicheck compare old.abi.json new.abi.json

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:

abicheck compare old.abi.json new.abi.json --suppress suppress-refused.yaml
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:

abicheck compare old.abi.json new.abi.json --suppress suppress-acknowledged.yaml
Verdict: NO_CHANGE (exit 0)   # explicitly acknowledged, not an accident of a broad glob

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.json
  • old.abi.json
  • suppress-acknowledged.yaml
  • suppress-refused.yaml

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