Case 182: Accidental Export Removed โ Still Breaking Under Public-Header Scoping¶
| Field | Value |
|---|---|
| Verdict | ๐ด BREAKING |
| Category | Breaking |
| Platforms | Linux |
| Flags | ABI break, Bad practice |
Detected ChangeKinds |
func_removed_elf_only |
| Source files | examples/case182_accidental_export_removed_still_breaking/ |
Category: Breaking | Verdict: ๐ด BREAKING
Verdict and consumer impact¶
internal_helper() is exported by the v1 library (default visibility, no
-fvisibility=hidden, no version script) but never declared in v1.h โ an
accidental export, the same pattern audited by
case143_audit_accidental_export.
v2 inlines its logic into public_api() and drops the symbol entirely.
public_api() itself is unchanged, so a consumer that only calls the
documented API notices nothing. But the absence of a header declaration
proves internal_helper wasn't part of the documented contract โ it does
not prove nobody depends on it. A consumer that obtained the symbol via
dlsym(), a leaked internal header, or a hand-written prototype fails at
lookup time once v2 removes it.
Old/new diff¶
| v1.h / v1.c | v2.h / v2.c |
|---|---|
int public_api(int x); (declared) |
int public_api(int x); (unchanged) |
int internal_helper(int x) { return x * 2; } (exported, undeclared) |
(removed โ inlined into public_api) |
int public_api(int x) { return internal_helper(x) + 1; } |
int public_api(int x) { return x * 2 + 1; } |
abicheck command¶
gcc -shared -fPIC -g v1.c -o libfoo_v1.so
gcc -shared -fPIC -g v2.c -o libfoo_v2.so
abicheck compare libfoo_v1.so libfoo_v2.so -H v1.h --ast-frontend clang
Expected abicheck finding¶
Verdict: BREAKING (exit 4)
- func_removed_elf_only: Elf_only function removed: internal_helper
> Exported function symbol removed from the binary; old binaries that
link or dlsym() it can fail even without header evidence.
--scope-public-headers is on by default and changes nothing here: nothing
is filtered, because nothing can be proven non-public. (Comparing the same
two .so files with no header at all, -H omitted entirely, reports the
same underlying fact as a plain func_removed.)
Minimum evidence¶
min_evidence: L0 โ the raw fact (a real, exported function symbol
disappeared) is visible from the ELF dynamic symbol tables alone; the
header above is not needed to detect the break, only to demonstrate that
public-header scoping โ which filters changes it can prove are
unreachable from the public surface โ does not launder this one away. A
bare ELF export with no header declaration at all cannot be proven
non-contractual, so it stays reported.
Why abicheck catches it¶
Public-surface scoping filters changes to types/symbols it can prove are
unreachable from the public-header surface โ an internal struct's layout
is only observable by a caller that names the type. But internal_helper
is a real, exported, callable symbol; its absence from v1.h is not
positive proof that nothing depends on it (it could still be reached via
dlsym() or direct linkage). abicheck's authority model never lets an
absence of positive evidence override negative evidence already proven at
the ELF layer โ a real, exported function that just disappeared โ so the
removal is folded back in and reported as BREAKING regardless of scoping.
That reconciliation (fold_l0_hard_removals()) is CLI-layer plumbing
invoked from the full abicheck compare pipeline shown above, not from the
lower-level checker.compare() API a faster test harness might call
directly โ since internal_helper has no header declaration on either
side, a direct dump()+compare() call never learns it existed and would
report NO_CHANGE. The command above is the real, user-facing CLI path,
which is what actually ships this BREAKING verdict.
Runtime failure demonstration¶
Severity: BREAKING for any consumer reaching for the undocumented export.
The shipped app.c only calls the documented public_api(), which stays
correct in both versions โ deliberately illustrating that the "obvious"
consumer notices nothing:
gcc -shared -fPIC -g v1.c -o libfoo.so
gcc -g app.c -I. -L. -lfoo -Wl,-rpath,. -o app
./app
# โ public_api(5) -> 11
gcc -shared -fPIC -g v2.c -o libfoo.so
./app
# โ public_api(5) -> 11 (still fine โ public_api's own contract never moved)
A consumer that instead reaches for the undocumented export directly (a
minimal dlsym() probe, not part of this case's checked-in fixtures) does
fail once v2 removes it:
./dlsym_demo # dlopen("./libfoo.so"), dlsym("internal_helper")
# against v1: internal_helper(5) = 10
# against v2: dlsym(internal_helper) FAILED: ./libfoo.so: undefined symbol: internal_helper
Why BREAKING: the symbol was real, exported, and callable in v1; a consumer that resolved it by name at load time or link time has no way to know it wasn't "supported" โ the header alone cannot prove that.
Safe redesign¶
If internal_helper truly has no external callers, ship the removal with a
major version bump / SONAME change to signal the break. If it must stay
ABI-stable for dlsym()-based consumers, keep the symbol exported (even as
a thin compatibility shim). Better yet, mark it static or hidden-visibility
from the start so it never becomes an accidental export in the next release
(see case06 for the visibility-based fix).
Cross-tool comparison¶
abidiff/abidw are not installed in this environment, so no output is
reproduced here. abidiff has no notion of public-header scoping at all โ
it treats every exported symbol as part of the ABI, so it would flag this
removal the same way regardless of the scoping question this case exists
to answer.
References¶
- Related cases:
case143_audit_accidental_export,case118_internal_struct_field_added_scoped,case164_preproc_conditional_field
Source files¶
CMakeLists.txtapp.cv1.cv1.hv2.cv2.h
See also: Examples overview ยท All BREAKING cases ยท Category: Breaking.