Skip to content

Fix/review 2026 08 - #156

Merged
ilmanzo merged 18 commits into
mainfrom
fix/review-2026-08
Aug 28, 2026
Merged

ilmanzo merged 18 commits into
mainfrom
fix/review-2026-08

Conversation

@ilmanzo

@ilmanzo ilmanzo commented Aug 28, 2026

Copy link
Copy Markdown
Owner

This pull request refactors and modernizes the function enumeration logic in cmd/enumerate.go and related ELF utility code, improving both accuracy and maintainability. The main changes include switching to a more robust function representation, preferring external debug files for symbol enumeration, and removing outdated or unsafe code paths. This update also prepares the codebase for future removal of deprecated behaviors related to system library modification.

Function enumeration and symbol resolution improvements:

  • Switches from using plain function name strings to a funkutil.Func struct that includes both mangled and demangled names, reducing redundant demangling and clarifying function handling throughout the codebase. (cmd/enumerate.go) [1] [2] [3] [4] [5] [6] [7]
  • Changes the function enumeration order to prefer external debug files' .symtab sections over the main binary's, ensuring more complete symbol coverage—especially for stripped binaries or shared libraries. (cmd/enumerate.go)
  • Updates the function enumeration API to return both a map of traceable functions and a map of demangled names, improving downstream logging and reporting. (cmd/enumerate.go) [1] [2] [3]

Deprecation and cleanup of unsafe or obsolete behaviors:

  • Removes the legacy mergeLibraryDebugInfo function and related code that previously modified system libraries in place, replacing it with a stub and comments marking it for future removal. (cmd/elfutil.go)
  • Refactors library dependency resolution to avoid using ldd and regular expressions, in favor of a safer, internal resolution mechanism. (cmd/enumerate.go) [1] [2]

General code quality and maintainability:

  • Moves the ELF build ID extraction logic to funkutil.BuildID, removing local duplication and unnecessary imports. (cmd/elfutil.go) [1] [2] [3]
  • Cleans up unused imports and simplifies map initialization. (cmd/enumerate.go, cmd/funkoverage.go) [1] [2] [3]

ilmanzo added 18 commits August 28, 2026 10:35
Five-phase execution plan from a whole-repo design/perf/cleanliness review:
  Phase 1 (C1-C4)  correctness bugs
  Phase 2 (D1)     stop mutating system shared libraries in place
  Phase 3 (E1-E5)  tracer and CLI cleanups
  Phase 4 (P1-P4)  report and enumeration performance
  Phase 5 (S1-S3)  small cuts

Highest value: C1 (install can leave a system binary deleted if the shim copy
fails after the original has been relocated) and D1 (mergeLibraryDebugInfo
eu-unstrips live system libraries, so rpm -V reports them modified and a crash
between backup and restore leaves the host permanently changed).

D1's replacement — resolving uprobe file offsets ourselves and attaching via
UprobeMultiOptions.Addresses — was validated read-only on the VM before
planning: build-ids match, the debug symtab is a strict superset (1040 vs 911
FUNC names on libgmp, 129 internal mpn_* gained), and the computed offset
disassembles to an instruction boundary. It also turned up the trap that makes
the whole phase dangerous to get wrong: the two files agree on PT_LOAD p_vaddr
but not p_offset, and every image on the test host has p_offset == p_vaddr, so
e2e cannot catch a botched conversion. Hence the mandatory build-id staleness
guard and a unit test cross-checking against cilium's own by-name resolution.

Includes the Phase 0 baseline, the VM e2e procedure, and a Rejected section
recording three review findings that collide with decisions already taken in
fix/audit-2026-08 (A4, A10, B4), so they don't get re-derived.
relocateOriginal moves the target out of its own path; the shim only lands
there later, in finishInstall. Between those two points install could return
an error with the path simply empty — the binary gone from the system. ENOSPC,
a read-only /usr, or a truncated shim all reach that window, and installMany
just logs the failure and moves on to the next target, so nothing notices.

enumerateAndPersist had an ad-hoc `_ = move(safePath, realTarget)` covering
only its own enumeration failure, with a comment noting that a later
WriteFuncList failure deliberately did NOT roll back. Replace it with a single
defer in install, armed as soon as relocateOriginal succeeds and disarmed once
finishInstall returns, so the whole window is covered symmetrically.

rollbackInstall does more than the old one-liner: it restores any system
libraries already unstripped in place, clears the sidecars keyed on safePath,
and removes the multicall alias (installMulticallSymlink now returns the link
it created for exactly this). A rollback that itself fails is reported as
CRITICAL on stderr instead of being folded into the returned error, which
installMany would otherwise print as one more line in a list of failed targets.

relocateOriginal keeps its own internal rollback: it runs before install has
anything to disarm.

Test injection point is worth recording: findShimBinary only os.Stats its
candidates, so pointing FUNKOVERAGE_SHIM at a directory is accepted and then
fails inside copyFile's io.Copy with EISDIR — precisely the target window, and
it behaves identically whether or not the test runs as root, unlike an
unreadable file.
uninstall cleared .funcs.json and (via restoreLibraryBackups) .libbackup.json,
but left .filter.json and .shimbin.json behind in SAFE_BIN_DIR, contradicting
AGENTS.md's "removes sidecars".

Factor the three safePath-keyed sidecars into clearSidecars, shared with C1's
rollbackInstall. The library-backup sidecar deliberately stays out of it:
restoreLibraryBackups owns that one, and must put the libraries back before
the record of where their backups live is thrown away.

Caught by the assertSafeBinDirEmpty helper added for C1's rollback test —
.shimbin.json was the survivor in TestInstallUninstall. .filter.json only
leaks when a filter was actually set, which no existing test did.
enumerateOne returned the first non-empty symbol list, checking the runtime
file before its external debug file. For a stripped/debuglinked pair that is
backwards: distro packaging moves the full .symtab into the debug file and
leaves the runtime file with only .dynsym, so every LOCAL/static function
lives in exactly one of the two. Any .dynsym export at all was enough to stop
the search and hide the rest — a CPython extension module resolved to just its
PyInit_* entry point.

Reorder to debug symtab -> own symtab -> DWARF. The superset premise was
checked on the VM across seven images first, counting defined functions only
(FUNC, Ndx != UND, non-zero size — what SymtabFunctions keeps):

  image                  runtime   debug   runtime-only
  libgmp.so.10.5.0           874     997              0
  libcrypto.so.3            5859   13101              0
  libssl.so.3                603    2512              0
  gzip                        99      99              0
  bzip2                       39      39              0
  squid                    15174   15174              0
  _bz2.cpython-313             1      19              0

Zero runtime-only symbols anywhere, so the reorder cannot lose anything.
_bz2 reproduces plan.md §3 exactly (1 vs 19); libssl is the biggest win.
Images with no debug file are untouched — externalDebugPath returns "".

The test reuses buildStrippedLibFixture, which already builds this shape for
the merge tests, and fails without the reorder with got [lib_public_func].

externalDebugPath is now called unconditionally, one extra elf.Open per image:
BenchmarkEnumerateFunctions 6 523 991 -> 6 688 722 ns/op (+2.5%). P4 (parallel
library enumeration) repays it.

plan.md §3 is pruned to the one part this does not close: the openQA
coverage_setup.pm debuginfo gap, which lives in os-autoinst-distri-opensuse.
ldd invokes the target binary's own PT_INTERP with
LD_TRACE_LOADED_OBJECTS=1, so `funkoverage install` — which runs as root —
executes code out of the very binary it is instrumenting. It also costs a
fork+exec per target.

Replace it with a DT_NEEDED walk in cmd/libdeps.go that applies ld.so's own
resolution rules: DT_RPATH/DT_RUNPATH with $ORIGIN and $LIB expansion,
LD_LIBRARY_PATH, a direct parse of the new-format /etc/ld.so.cache, and the
default library directories. ParseLddLibraries moves into the same file and
stays as the fallback for any SONAME the walk cannot resolve, so a host whose
layout is not fully understood keeps today's exact behaviour instead of
quietly enumerating a subset.

Two ld.so rules matter more than they look:

  - glibc-hwcaps subdirectories are searched before the directory itself,
    and openSUSE ships x86-64-v3 builds of libz, libzstd, libjpeg, liblz4
    and brotli. Resolving to the baseline file while the process maps the v3
    one attaches the uprobe to the wrong inode, which never fires and
    reports 0% for that library with no error anywhere.
  - $ORIGIN is relative to the realpath of the file ld.so opened, not to the
    path as given.

Both were found by the new test, which diffs the resolver against ldd over
the host's own binaries; it matches on all 319 of /usr/bin here, with no
fallbacks. BenchmarkEnumerateFunctions 6.52ms -> 4.25ms.
UprobeMulti resolves the whole name batch up front and fails on the first
miss, so a single unresolvable name silently costs an entire library's
coverage.

C3 made enumeration prefer the external debug file's symtab, which exposed
this on distributions where the install-time eu-unstrip merge is refused
(Leap 16: "cannot find matching section for [10] '.rela.dyn'"). There the
debug-only names are not in the file the kernel maps, and openssl fell from
1824 to 181 called functions.

attachableSymbols pre-filters each image's batch against the names the mapped
file can actually resolve, keeping every survivor paired with the cookie
flattenFuncs assigned it. ResolvableFuncNames deliberately mirrors cilium's
own predicate, since that is the cache UprobeMulti consults.

Leap 16 openssl is back to 1824 called against C3's richer 16273 denominator;
Tumbleweed, where the merge succeeds and nothing is filtered, is unchanged at
3969/16297. Both container suites pass 9/9.
…(D1)

funkoverage ran eu-unstrip over /lib64/libfoo.so.N on the live host so that
uprobe attach could resolve a library's internal functions by name. That made
rpm -V report every touched library as modified, left the host permanently
modified if the process died between backup and restore, and raced between
concurrent installs.

Instead, install time now partitions each image's accepted symbols: those the
runtime file's own symtab/dynsym can resolve keep attaching by name, and those
found only in the external debug file get their file offsets pre-computed and
attach via link.UprobeMultiOptions.Addresses. Offsets take symbol values from
the debug file and program headers from the runtime file -- --only-keep-debug
preserves p_vaddr but not p_offset, so using the debug file's headers puts the
probe mid-instruction (libgmp: 0x11000 too low).

Two hazards found on real distro libraries, neither reachable from the libgmp
pair the design was drawn from:

  - UprobeMulti resolves the whole batch up front and fails the entire call on
    the first bad entry. The x86 uprobe decoder refuses hand-written AES-NI asm
    (_aesni_ctr32_ghash_6x, _aesni_ctr32_6x) with EOPNOTSUPP, and those two
    symbols cut openssl from 3971 to 2185 called. attachBisect halves the batch
    on error, so a bad entry drops only itself.

  - openSUSE Leap 16 ships libopenssl3-debuginfo built from a different build
    of libcrypto than the library on disk (780e425a... vs b844407f..., different
    segment sizes, no .build-id symlink). Its symbol values describe other code,
    and openssl version -a died with SIGSEGV. SymbolFileOffsets now returns
    nothing unless the two files' build-ids match, so no caller can express the
    unsafe pairing; Leap 16 falls back to name-only attach automatically.
    attachPlan.verify re-checks the build-id against the mapped library at
    attach time, catching a package upgrade between install and run.

.funcs.json carries ImageFuncs{BuildID, Names, Offsets} and still reads the
pre-0.8.4 bare array as a name-only plan. restoreLibraryBackups survives only
to clean up installs made by 0.8.3 and goes one release after 0.8.4.

test_gmp.sh is rewritten as the gate: libgmp is byte-identical across the whole
install/uninstall cycle, rpm -V is unchanged, and coverage is unchanged at
15/98. Full sweep green on the VM host and in both container images; coverage
up in all three packages (cmd 75.4 -> 77.1, shim_binary 25.3 -> 30.8, funkutil
90.9 -> 92.1).
E1: the rootPID field was written by Start and never read. imgSymbols and
imgCookies, the plan's other two targets, had already been replaced by plans
and scanned in D1.

E2: Tracer.Stop returned an always-nil error; both call sites ignored it.

E3: handleDynamicLoad runs on the readLoop goroutine, so nothing drains the
ringbuf while it works, and it re-parsed the symbol tables of every library it
had previously rejected on every dlopen event. The instrumented set is now
named scanned and is marked before the noisy/filter/symtab checks rather than
after a successful attach, so a rejected library stays rejected. Still no
dedicated goroutine: kernel-side dedup plus a 256 KB ringbuf means overflow
needs a pathological plugin load, and the ponytail comment names the upgrade
path if one shows up.

E4: every subcommand built its FlagSet with flag.ExitOnError, so fs.Parse
exited the process behind main's back and every `if err := fs.Parse(...)`
branch in the file was unreachable -- cmdUninstall and cmdTrace did not even
have one. newFlagSet uses ContinueOnError and discards flag's own output, so a
bad flag surfaces exactly once through main's uniform "<cmd> error: ..." path
with exit 1 instead of flag's exit 2. -h returns flag.ErrHelp, which main
answers with helpText.

E5 (scale the "seen" map's dlopen headroom off the static function count) was
implemented and rejected: test_nginx_dlopen.sh failed on a missing
ngx_http_echo_handler, because the dlopen path instruments every newly mapped
non-noisy library rather than just the plugin, and nginx's 3940 static
functions bought only 1970 dynamic slots. The measurement is recorded in the
constant's doc comment. The 800 KB reserve stands until a BPF_MAP_TYPE_HASH
"seen" map is worth its hot-path lookup.

Full sweep green: VM host binaries, dlopen/relay/symlink tests, the python
suite, and both container images. cmd coverage 77.1 -> 79.1, shim_binary
30.8 -> 31.2.
With the default --formats html,txt,xml, splitCalledUncalled ran once per
image per format and summarizeCoverage twice. buildReportSet does both once
into a reportSet; emitReport, perImage and the three generators consume that
instead of the coverage map.

generateAggregateHTMLReport now clones Rows before shortening image names to
their base — it used to own a fresh summarizeCoverage result, and rewriting a
shared one corrupts the txt and xml output.

BenchmarkEmitReportMultiFormat (new, the two-format path the item is about):
8.6 -> 7.4 ms. No other benchmark moved outside noise.
printTxtReport used fmt.Printf against os.Stdout, which Go does not buffer:
one write(2) per function name. It now wraps the writer it is handed in a
bufio.Writer and flushes on return, and emitReport passes os.Stdout.

5000-function corpus, report --formats txt, strace -c -e trace=write:
5015 -> 42 writes, output byte-identical.
SymtabFunctions demangled every symbol to evaluate keep, then threw the
result away; writeFunctionsLog and `enumerate` demangled the whole set
again, and the shim's dlopen path did it three times per library. It now
returns funkutil.Func{Raw, Demangled} and the demangled names travel out of
EnumerateFunctions as a second map, keyed like the first.

Not a field on ImageFuncs: that type is the .funcs.json schema, and the
display names are neither serialised nor of any use to the shim.

enumerate --no-libs /usr/lib64/libstdc++.so.6 (3986 C++ symbols): 41 -> 26 ms.
The dependency loop in EnumerateFunctions parsed each library's ELF and
DWARF one at a time, so a single large dependency held up every other one
behind it. Run it under an errgroup limited to GOMAXPROCS(0): each goroutine
fills one preallocated slot and both result maps are merged from that slice
after Wait, in libs order, so the maps are never written concurrently and
the merge does not depend on completion order. Per-library errors stay
per-library warnings.

enumerate /usr/bin/clang-tidy-22 (7 traceable libraries, 64608 functions):
393 -> 249 ms, byte-identical output. BenchmarkEnumerateFunctions cannot
show it — the sample binary's only dependencies are core system libs, which
IsSystemLib drops, leaving the loop empty; interleaved runs against the
parent commit overlap.

Closes bottleneck C.3 of issue #123.
S1: drop the make(map[string]command, len(cmds)+14) size hint — a hand
synced magic number that had to track the alias block below it.

S2: calledLogPath becomes newLogPaths, returning {Called, Functions} built
from one timestamped stem. NewTracer takes the pair instead of deriving the
functions log by string-editing "_called.log" out of the called log.

S3: writeBuffered now reports the close error when nothing else failed. On
a full filesystem the failing write is often the one close performs, so
dropping it turned a truncated report into a successful run — that applies
to the HTML and XUnit writers too, not just writeFunctionsLog, which now
calls into it instead of repeating the create/buffer/flush dance.
This reverts commit a27a455, keeping its test.

Measured, then judged not worth it: clang-tidy-22 (7 traceable libraries,
libLLVM + libclang-cpp, 64608 functions) 393 -> 249 ms, ssh 12.8 -> 11.2 ms,
git unchanged. That is 144 ms saved once, on a path that runs only during
install and trace startup, in exchange for a goroutine fan-out whose
per-library stderr warnings now race and whose merge order the code has to
defend deliberately.

TestEnumerateFunctions_WithLibraries stays: the dependency loop had no test
at all before, and the benchmark cannot cover it because tests/sample/sample
depends only on libraries IsSystemLib drops.

Root plan.md gains section 4 recording the decision and the numbers, since
docs/review-2026-08.md is deleted when this branch merges. Bottleneck C.3 of
issue #123 is closed as decided against, not as done.
... | sort -rn | head -10 under set -euo pipefail: head closes its input
after 10 lines, sort gets SIGPIPE, pipefail turns that into the pipeline's
exit status, and set -e kills the whole script -- right after the (purely
cosmetic) namespace list had already printed correctly. Never manifested
running the script directly; reliably killed it under container scheduling,
reproduced twice via bash -x. Trailing "|| true" matches the pattern already
used two lines above for the same reason.

Pre-dates this branch (5ebfbcf); found while running the pre-merge e2e
sweep for fix/review-2026-08, unrelated to any of its phases.
test_glibc_hwcaps_resolution.sh: C4's ld.so glibc-hwcaps priority
(<dir>/glibc-hwcaps/<level>/<soname> before <dir>/<soname>) was verified
once, manually, against 319 host binaries, never as a regression test.
Getting it wrong attaches a uprobe to an inode the process never maps --
no error, just silent 0% for that library. Uses bzip2/libbz2, a real
distro pair with a plain and a v3-optimized build on disk (different
sha256, same soname); asserts enumerate resolves to the exact file ldd
says the process maps, then that image shows called functions post-run.
Verified as a real guard, not a tautology, by forcing hwcapsSubdirs() to
return nil and confirming the assertion fails with the expected message.
Self-skips (not fail()) when the host/distro ships no hwcaps build of
libbz2 at all -- true on both container images, unlike the bare VM.

test_rust_ripgrep.sh: every existing demangling assertion in this suite
is C++ (squid). demangle.Filter also handles Rust's mangling scheme;
nothing exercised it before. Asserts a trait-impl name
(<T as Trait>::method), a shape Itanium C++ demangling never produces,
so it proves the Rust grammar is actually parsed rather than a generic
"::" check coincidentally passing.

Both wired into run_all_container_tests.sh, shared by test_container_
leap16.sh and test_container_tumbleweed.sh. Also added test_nss_dlopen.sh
and test_pam_dlopen.sh to the container runner -- part of the VM sweep
already but never wired into containers. Full container sweep re-run
after all additions: 13 scripts, both distros green.
…tion)

All items landed: C1-C4, D1, E1-E4 (E5 rejected), P1-P3, P4 (rejected, see
plan.md #4), S1-S3. Full VM e2e sweep plus both containerized suites
(leap16-e2e, tumbleweed-e2e, 13 scripts each) green, including two new
tests (glibc-hwcaps resolution, Rust demangling) added while running it.

plan.md #2 (ldd fork-exec elimination) is done via cmd/libdeps.go's
DT_NEEDED walk -- delete it. #1 is unrelated to this review, #3 keeps its
already-trimmed openQA packaging gap, #4 (P4 rejection) stays as the record
of a decision this branch's own working doc can no longer carry.
This resolves a CI failure on Ubuntu 24.04 where ldd failed to find dependencies of symlinked binaries like appletviewer due to $ORIGIN relative path mismatches.
@codecov

codecov Bot commented Aug 28, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 81.58915% with 95 lines in your changes missing coverage. Please review.
✅ Project coverage is 67.03%. Comparing base (d6d3423) to head (eb6414a).

Files with missing lines Patch % Lines
cmd/shim_binary/tracer.go 58.24% 38 Missing ⚠️
cmd/libdeps.go 82.92% 28 Missing ⚠️
cmd/enumerate.go 88.88% 9 Missing ⚠️
cmd/funkoverage.go 75.00% 7 Missing ⚠️
cmd/shim.go 70.83% 7 Missing ⚠️
internal/funkutil/buildid.go 88.88% 2 Missing ⚠️
internal/funkutil/symtab.go 95.34% 2 Missing ⚠️
cmd/report.go 97.82% 1 Missing ⚠️
cmd/shim_binary/main.go 75.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #156      +/-   ##
==========================================
+ Coverage   59.79%   67.03%   +7.23%     
==========================================
  Files          18       20       +2     
  Lines        1495     1805     +310     
==========================================
+ Hits          894     1210     +316     
+ Misses        601      595       -6     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@ilmanzo
ilmanzo merged commit cd3f6db into main Aug 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant