Skip to content

Write saved GOT slots only while their library is loaded - #838

Merged
rkennke merged 8 commits into
mainfrom
fix/prof-16135-stale-got-slots
Oct 6, 2026
Merged

rkennke merged 8 commits into
mainfrom
fix/prof-16135-stale-got-slots

Conversation

@rkennke

@rkennke rkennke commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

Fixes PROF-16135. Builds on #837 (merged), which builds this PR's test DSO.

What does this PR do?:

Makes LibraryPatcher write a saved GOT slot only while the library that owns it is provably still loaded. That covers socket I/O hooks, pthread_create and sigaction, on both stop and start.

Motivation:

LibraryPatcher stores raw GOT-slot addresses, and CodeCacheArray never drops a CodeCache. Nothing pinned the patched libraries. Once one was dlclose()d and unmapped:

  • stop restored the hooks through the stale address (unpatch_socket_functions, unpatch_libraries);
  • start, after a stop, re-scanned the never-pruned library list and patched the stale address again (patch_socket_functions, patch_pthread_create). So an unload while the profiler was stopped crashed the next start.

The result is a SIGSEGV if the range is unmapped, and silent corruption if another mapping reused it. The ticket only described the socket-unpatch case; the restart path and pthread_create (which isn't gated on natsock) have the same bug. The old in-code justification ("the host JVM does not dlclose libc-importing DSOs") is false: OpenJDK unloads a JNI library when its class loader is collected (NativeLibraries → JVM_UnloadLibrary → os::dll_unload → dlclose). The realistic trigger is a plain-C JNI library on glibc, e.g. on app-server redeploys. C++ libraries with STB_GNU_UNIQUE symbols, and anything on musl, never actually unmap.

The change:

All three patchers now go through visit_live_libraries(), which makes each write inside a dl_iterate_phdr() callback:

  • Why that's race-free: glibc's _dl_close_worker unmaps (DL_UNMAP) and unlinks an object while holding dl_load_write_lock, and dl_iterate_phdr holds that lock for the whole walk. So an object reported to the callback cannot be unmapped until the callback returns. Verified in elf/dl-close.c and elf/dl-iteratephdr.c for glibc 2.17, 2.28, 2.35 and current master.
  • Identity: a CodeCache matches a loaded object only with the same image base and the same image fingerprint. The backing file is used only for a cache without a fingerprint.
    • Image fingerprint: FNV-1a over the program header table, the PT_NOTE segments (which hold the GNU build-id, if any) and the import layout: every dynamic relocation that names a symbol, from DT_JMPREL and DT_RELA/DT_REL (offset, type, symbol index and the symbol's name). It's recorded at parse time (CodeCache::imageFingerprint()) and recomputed from dl_phdr_info in the callback. Unlike the file, it still identifies the library when the loader's name no longer resolves to it, because the file was unlinked after loading, or the name is relative or a symlink. This matters: netty's NativeLibraryLoader deletes extracted libraries by default (io.netty.native.deleteLibAfterLoading=true).
    • Backing file: stat(dlpi_name) dev/inode vs the new CodeCache::fileId(), recorded from /proc/self/maps, and only for a cache without a fingerprint. It doesn't identify the image: a different build can be written to a file that reuses a freed inode (and is then never parsed, because that dev/inode has been seen), and a relative or symlinked name can come to point at another file. (Checked on a real process: inode matching holds across merged-/usr paths and versioned symlinks, where plain name comparison doesn't.) In practice every cache with imports has a fingerprint, since both are set in parseProgramHeaders().
    • Why the base alone isn't enough: a different library can be loaded at a stale cache's base. Accepting a fingerprint match is safe because equal fingerprints at the same base mean that each saved slot address holds the same import. That holds without a build-id too: two builds that differ only in importing write() or read() have identical program headers, and only the import layout tells them apart.
  • Reloads at the same base: a reload of the same file at the same address is still represented by the old CodeCache (the reload isn't re-parsed, since its inode has been seen). Its saved slots stay valid, but under full RELRO the new mapping's GOT is read-only again. So the imports are made writable again before every visit, instead of trusting the cache's earlier state.
  • Dropped entries: entries of unloaded libraries are dropped without being written.
  • musl: the check is skipped, since musl's dlclose() never unmaps.

Additional Notes:

Please push back on these:

  1. Lock ordering: dl_load_write_lock, then _lock. Patching and unpatching run in a LiveLibraryWalk, which takes the patch lock (_lock) in the first dl_iterate_phdr callback, collects its candidates there and holds the lock until it is destroyed.
    • Why not the other way round: dl_iterate_phdr runs arbitrary callbacks under dl_load_write_lock. One that loads a library through the JVM (a JNI System.loadLibrary()) reaches Profiler::dlopen_hook() and a synchronous refresh that patches. Had a concurrent stop or refresh held _lock while waiting for the write lock, the two would wait for each other forever. In this order the refresh re-enters the recursive write lock and waits for _lock, whose holder waits for nothing.
    • What that requires: code under _lock must not wait for dl_load_lock or dl_load_tls_lock, which a concurrent dlopen/dlclose holds while waiting for the write lock. Nothing under it calls dlopen, dlsym, dladdr or dlinfo (the dlsym calls of patch_socket_functions() stay outside the walk). It does mprotect, stat, TEST_LOG/Log::warn (vsnprintf/fprintf) and relaxed atomic counter updates, and touches no dynamic TLS.
    • musl skips the walk and just takes _lock.
  2. Behavior change: libraries in another linker namespace (dlmopen) are no longer patched, because dl_iterate_phdr reports only the caller's namespace.
  3. Not covered (none of these crash; all predate this PR):
    • A relatively loaded library whose file is gone before it's parsed never has its imports read (UnloadProtection can't dlopen it by the absolute path). It was never patched before this PR either.
    • A library unloaded and reloaded at the same base while patched is not re-hooked: has_entry_for (pthread_create, sigaction) and the slot-location check (sockets) treat it as already patched. It runs unhooked until the next stop/start; for sigaction, whose entries are never cleared, for the rest of the process.
    • A library reloaded at a different base is never re-parsed (its dev/inode has been seen), so it's never hooked.
  4. Existing test changed: StillPatchesForeignPthreadCreateSlot used to expect a fake, never-loaded CodeCache's slot to be written back on unpatch. It now asserts the entry is dropped without a write, which is the point of the fix. Restoring a real loaded library is covered by UnpatchRestoresLoadedLibrary/PthreadCreate.
  5. Cost: every patch and unpatch call enters dl_iterate_phdr to take the locks in order. With no candidates (prefilters keep only libraries that still need work) it stops after the first object. Once a library has been unloaded, though, its stale cache stays a candidate, so from then on every patch pass walks all objects, briefly holding dl_load_write_lock and blocking other threads' exception unwinding. Per object at a candidate's base it hashes the program headers, notes and symbol-naming relocations once (leading relative relocations are skipped via DT_RELACOUNT); stat runs only for a cache without a fingerprint. No allocation: candidates live in a static array guarded by _lock, sized for one candidate per CodeCache (asserted).

A dlclose hook was considered and rejected. It can't see unloads while the profiler is stopped (the trap is disabled), native code calling dlclose directly, or dependencies unloaded along with the closed library. And with this check in place, it isn't needed.

How to test the change?:

libraryPatcherUnload_ut.cpp (new, 26 test cases) uses small plain-C DSOs (native-libs/patch-target-lib): one importing write() and pthread_create(), two that differ only in build-id, and two without a build-id that differ only in importing write() or read(). Each scenario runs in a forked child, because patching is process-global and the bug crashes.

Test (×Socket, ×PthreadCreate where parameterized) Before
UnpatchDoesNotWriteIntoReusedMapping corrupts the page mapped at the old address
UnpatchDoesNotFaultOnUnmappedLibrary SIGSEGV
RepatchDoesNotWriteIntoReusedMapping restart corrupts the reused page
RepatchAfterReloadAtSameBase / UnpatchAfterReloadAtSameBase SIGSEGV on the reloaded, read-only GOT
UnpatchRestoresLoadedLibrary passes (positive control)
PatchSkipsCacheOfDifferentImageAtSameBase, PatchSkipsCacheOfSameFileButDifferentImage, PatchAcceptsCacheOfSameImageAtSameBase, Patch{Accepts,Skips}CacheWithoutFingerprintOf{Same,Different}File identity rules; with file-or-fingerprint matching, …SameFileButDifferentImage (inode reuse) wrote the stale cache's slot
PatchesLibraryUnlinked{Before,After}Parsing, …RelativePathAndUnlinked, …SymlinkAndUnlinked, PatchSkipsUnlinkedCacheOfDifferentImage unlinked or aliased libraries
FingerprintCoversBuildId two builds that differ only in build-id get different fingerprints
FingerprintCoversImports two build-id-less builds that differ only in importing write()/read() got the same fingerprint
UnpatchDoesNotWriteIntoReplacementWithSameLayout after the write() build is replaced by the read() build at the same base, stop restored write() into the slot holding read()
PatchInsideForeignWalkDoesNotDeadlockWithStop patching from inside a foreign dl_iterate_phdr callback while another thread stops: deadlock (fails on a 10 s alarm)

The test DSOs are built with full RELRO (-z now), as on hardened distros, and with the build host's page size as max-page-size: with aarch64's default 64K segment alignment glibc over-allocates the mapping, so a reload never lands at the old base and the reload tests always skipped there.

  • Each check was verified to matter by temporarily removing it; each removal fails exactly the tests that cover it:
    • without the liveness check: the stale-slot tests and the different-image test;
    • without fingerprint precedence (accepting an inode match despite a different fingerprint): PatchSkipsCacheOfSameFileButDifferentImage;
    • without the fingerprint fallback: the unlinked, relative and symlink tests and the same-image test;
    • without the notes in the fingerprint: FingerprintCoversBuildId;
    • without the import layout in the fingerprint: FingerprintCoversImports and UnpatchDoesNotWriteIntoReplacementWithSameLayout;
    • with the old lock order (_lock taken before the walk): PatchInsideForeignWalkDoesNotDeadlockWithStop;
    • without re-making the GOT writable: the reload tests SIGSEGV.
  • The tests can't skip after a fix that pins libraries: whether dlclose can unmap at all is checked before the profiler sees the DSO, and they skip only if it can't (musl).
  • Local runs, linux-x64 glibc 2.35 (before the fingerprint-precedence change): all gtestDebug tests pass. Run CI-style (buildGtest{Release,Asan}, then every binary directly), 75 release and 74 ASan binaries pass. testDebug passes 225/225 (19 environment skips; all NativeSocket* tests ran).
  • Current head, linux-aarch64 glibc 2.35 container: all 75 gtestDebug binaries pass; libraryPatcherUnload_ut 26/26 with no skips in both debug and ASan; libraryPatcher_ut passes under ASan. testDebug -Ptests="*NativeSocket*": all pass, including NativeSocketRestartTest (NativeSocketMacOsNoOpTest skips off macOS).

For Datadog employees:

  • If this PR touches code that signs or publishes builds or packages, or handles
    credentials of any kind, I've requested a security review (run the dd:platform-security-review
    skill, or file a request via the PSEC review form).
    bewaire also runs automatically on every PR.
  • This PR doesn't touch any of that.
  • JIRA: PROF-16135

🤖 Generated with Claude Code

@rkennke
rkennke requested a review from a team as a code owner October 5, 2026 14:12

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3743d83d15

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread ddprof-lib/src/test/resources/native-libs/patch-target-lib/Makefile
Comment thread ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp Outdated
@datadog-prod-us1-4

datadog-prod-us1-4 Bot commented Oct 5, 2026 •

Copy link
Copy Markdown

Pipelines

This comment will be updated automatically if new data arrives.
🔗 Commit SHA: d8eeb61 | Docs | View more details | Give us feedback!

@datadog-prod-us1-4 datadog-prod-us1-4 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

The liveness predicate can still recognize a stale CodeCache after a same-file reload at the same base, allowing writes into a newly protected GOT. Deleted-file path matching also aliases replacements and rejects live relative-path loads.

Open Bits AI session

🤖 Bits Code Review · Commit 3743d83 · @DataDog review to ask questions

Comment thread ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp Outdated
Comment thread ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp Outdated
Comment thread ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp Outdated
@rkennke
rkennke force-pushed the fix/prof-16135-stale-got-slots branch from 3743d83 to 660ef43 Compare October 5, 2026 15:19
@dd-octo-sts

dd-octo-sts Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

CI Test Results

Run: #37459854565 | Commit: d141a39 | Duration: 2h 21m 16s (longest job)

✅ All 32 test jobs passed

Status Overview

JDK glibc-aarch64/debug glibc-amd64/debug musl-aarch64/debug musl-amd64/debug
8 - ✅ - -
8-ibm - ✅ - -
8-j9 ✅ ✅ - -
8-librca - - ✅ ✅
8-orcl - ✅ - -
11 - ✅ - -
11-j9 ✅ ✅ - -
11-librca - - ✅ ✅
17 ✅ ✅ - -
17-graal ✅ ✅ - -
17-j9 ✅ ✅ - -
17-librca - - ✅ ✅
21 ✅ ✅ - -
21-graal ✅ ✅ - -
21-librca - - ✅ ✅
25 ✅ ✅ - -
25-graal ✅ ✅ - -
25-librca - - ✅ ✅

Legend: ✅ passed | ❌ failed | ⚪ skipped | 🚫 cancelled

Summary: Total: 32 | Passed: 32 | Failed: 0


Updated: 2026-10-06 17:46:45 UTC

@dd-octo-sts

dd-octo-sts Bot commented Oct 5, 2026 •

Copy link
Copy Markdown
Contributor

✅ All 40 integration tests passed

📊 Dashboard · 👷 Pipeline · 📦 d8eeb61f

@rkennke
rkennke deleted the branch main October 5, 2026 16:48
Base automatically changed from build/gtest-native-libs to main October 5, 2026 16:48
@rkennke rkennke closed this Oct 5, 2026
@rkennke rkennke reopened this Oct 5, 2026

@datadog-prod-us1-4 datadog-prod-us1-4 Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bits Code Review: FAIL

For unlinked DSOs, the new fallback equates path with identity: a replacement DSO can inherit a stale cache at the same base, while a live DSO loaded through a relative path or symlink can be missed. Those cases can corrupt GOT contents or leave hooks installed.

Open Bits AI session

🤖 Bits Code Review · Commit 660ef43 · @DataDog review to ask questions

Comment thread ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp Outdated
Comment thread ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp Outdated
@rkennke
rkennke force-pushed the fix/prof-16135-stale-got-slots branch from 660ef43 to d1bb979 Compare October 5, 2026 16:57
rkennke and others added 4 commits October 6, 2026 09:11
LibraryPatcher records the GOT slot addresses it patches for socket I/O,
pthread_create and sigaction, and the library list never drops a
CodeCache. Nothing pinned the patched libraries, so after a library was
dlclose()d and unmapped, restoring the hooks on stop, or re-installing
them on the next start, wrote through the stale address: a SIGSEGV if
the range was unmapped, silent corruption if another mapping reused it.
OpenJDK unloads a JNI library when its class loader is collected, so
this is reachable from application code.

Route every such write through visit_live_libraries(), which makes it
from inside a dl_iterate_phdr() callback. glibc's dlclose() unmaps and
unlinks an object while holding dl_load_write_lock, the lock the
iteration holds throughout, so a library reported to the callback stays
mapped until the callback returns. A CodeCache is matched to a loaded
object by image base and by the dev/inode of the backing file, recorded
from /proc/self/maps as CodeCache::fileId(). When the file was unlinked
after loading, as netty's native loader does by default, the match falls
back to the path. Entries of unloaded libraries are dropped without
being written. musl's dlclose() never unmaps, so musl skips the check.

Add libraryPatcherUnload_ut, which loads a small C library importing
write() and pthread_create(), patches it, unloads it and then stops or
restarts patching. It covers both the unmapped and the reused-address
case, a different file at the same base, unlinked library files, and a
still-loaded library being restored. StillPatchesForeignPthreadCreateSlot
now expects a slot whose library is not loaded to be left alone on
unpatch.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A library unloaded and reloaded from the same file at the same address
still matches its old CodeCache by base and inode, which is intended:
the reload is not re-parsed, and the saved slots are valid for the
identical layout. But the cache remembered that its GOT had been made
writable, while under full RELRO the new mapping's GOT is read-only, so
the next restore or re-patch faulted. Make the imports writable again
for every library confirmed loaded, before visiting it.

Tighten the fallback used when the backing file was unlinked after
loading. A relatively loaded library keeps its relative name in the
loader while /proc/self/maps has the absolute path, so match a relative
name as a trailing part of the path. And require the program header
table to match a hash recorded at parse time, so that a different
library loaded from the same path, unlinked too and mapped at the same
base, is not taken for the old one.

Build the test library with full RELRO and test reloads at the same
base for stop and restart, a relatively loaded unlinked library, and an
unlinked cache with a different layout. Add the copyright header to the
test library's Makefile.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When the name a library was loaded by no longer resolves to its file,
because the file was unlinked or the name is relative or a symlink, the
liveness check fell back to comparing that name with the path from
/proc/self/maps. That breaks for symlinks and for relative names that
resolve differently, and a path says nothing about which file was
loaded from it.

Replace the path comparison with an image fingerprint: a hash of the
program header table and of the PT_NOTE segments, which carry the GNU
build-id, recorded at parse time and recomputed from dl_phdr_info inside
the dl_iterate_phdr() callback. A loaded object is now the cached
library if it has the same base and either the same backing file or the
same fingerprint. Matching the fingerprint means the same build at the
same address, whose GOT slots are where the cache expects them.

Add tests for a library loaded through a symlink, for each identity on
its own, and for two builds that differ only in their build-id.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
musl's dlclose() never unmaps, so the patcher visits every candidate
there without checking its identity, and a test that expects a stale
cache to be rejected cannot pass. The other rejection tests already skip
on musl through the check that dlclose() can unload the test library.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke
rkennke force-pushed the fix/prof-16135-stale-got-slots branch from a9f13c2 to 1340a26 Compare October 6, 2026 09:15
rkennke and others added 2 commits October 6, 2026 12:02
The liveness check accepted a cached library when either its backing
file or its image fingerprint matched the loaded object at the same
base. The file does not identify the image: a different build can be
written to a file that reuses the freed inode of an unloaded library,
and is then not parsed at all, because its dev/inode has been seen
before. A relative or symlinked name may also resolve to another file
by the time of the check. In both cases the stale CodeCache was taken
for the loaded object and its saved GOT slots were written into it.

Let the fingerprint decide whenever the cache has one, and fall back to
the backing file only for caches without a fingerprint. The file is now
stat()ed lazily, only in that case.

PatchAcceptsCacheOfSameFileAtSameBase becomes
PatchSkipsCacheOfSameFileButDifferentImage, and two tests cover the
fallback for a cache without a fingerprint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
State why add_live_candidate() cannot run out of room, and assert it,
instead of silently dropping a candidate: every caller adds at most one
per CodeCache, and a library's socket entries form a single run.

Narrow the visit_live_libraries() contract to LibraryPatcher's own
writes and name the exception: MallocHooker patches GOT slots while
holding an UnloadProtection on the library and never restores them.

In the tests, describe the unlinked-library case by the fingerprint
rather than by path, which it no longer uses, give FingerprintCoversBuildId
its own exit code, remove the temporary library copy when a child bails
out early, drop the ticket references from comments, and use the SPDX
copyright header.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke

rkennke commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Reviewed revision d7b93e9e4cd3cfc5a758d44ad8515edd4d8b4702. Two findings:

[P1] Do not use a headers-only fingerprint as image identity

Location: symbols_linux.cpp:1293–1307, accepted by libraryPatcher_linux.cpp:123–128.

imageFingerprint() hashes program headers and mapped PT_NOTE segments, but returns a nonzero fingerprint even when there is no build ID. Different DSOs with identical program-header layouts can therefore pass the identity check, regardless of their different backing files and imports.

I reproduced this in an isolated Linux/aarch64 glibc probe using the exact fnv1a() and Symbols::imageFingerprint() implementations from this revision. Build two plain-C DSOs with -shared -fPIC -Wl,--build-id=none,-z,now, exporting the same entry(int, void*, size_t) function: one calls write(), the other calls read(). Load the first, save its write GOT slot and original pointer, unload it, then load the second. They loaded at the same base, had the same fingerprint, and placed the read import at the saved slot address:

same_base=1 same_fingerprint=1 same_slot=1 fingerprint=f6ead78ca3a2c389
before: replacement slot points to read=1
after accepted restoration: replacement read slot points to write=1

The final step applied the restoration that this predicate permits, after making the page writable. It corrupted the replacement library's read slot. This leaves the address-reuse case in PROF-16135 unresolved for libraries without build IDs. Please require stronger image/load identity and add a regression test with two actual replacement DSOs without build IDs; tests with artificially different fingerprints do not exercise this case.

[P1] Avoid acquiring the loader lock while holding the patch lock

Location: libraryPatcher_linux.cpp:70–83.

The new walk runs while _lock is held. glibc's dl_iterate_phdr() holds dl_load_write_lock across arbitrary caller-supplied callbacks, so the assertion that other callers cannot enter code taking _lock is too strong. For example, an application's callback can call into Java through JNI and load a native library, reaching the patched libjvm dlopen entry, Profiler::dlopen_hook() → synchronous Libraries::refresh() → patching → _lock.

With concurrent patching, the lock cycle is:

  • Patcher thread: holds _lock, waits for dl_load_write_lock in visit_live_libraries().
  • Application callback thread: holds dl_load_write_lock, enters profiler refresh, waits for _lock.

I verified this lock cycle in a reduced two-thread Linux probe using the repository's actual SpinLock/ExclusiveLockGuard and real glibc dl_iterate_phdr() callbacks. Both threads remained blocked until the three-second timeout. The callback modeled refresh's patch-lock acquisition; this was not an end-to-end JVM reproduction. Please use a consistent lock order or otherwise avoid waiting for the loader lock while holding _lock, and add a concurrent regression test.

Validation scope: these were isolated Linux probes, not a run of the full profiler test suite. The fingerprint probe demonstrated the replacement-slot corruption directly; the deadlock finding combines source inspection of the JVM hook/refresh path with a reduced reproduction of the lock cycle.

rkennke and others added 2 commits October 6, 2026 13:29
The fingerprint that identifies a loaded library hashed its program
header table and its PT_NOTE segments. Without a build-id that is just
the segment layout, so two different libraries with identical program
headers, such as two builds that differ only in importing write() or
read(), got the same fingerprint. Loaded at the same base, the stale
CodeCache of one was taken for the other, and stopping restored the
saved write() binding into the GOT slot that holds read() in the
replacement.

Also hash every dynamic relocation that names a symbol, from DT_JMPREL
and DT_RELA/DT_REL: its offset, type and symbol index, and the symbol's
name. Equal fingerprints at the same base now mean that each saved slot
address holds the same import. The tables are read only within the
file-backed parts of the PT_LOAD segments, which replaces the explicit
readable range: at parse time the library is held by UnloadProtection,
as parseDynamicSection() already relies on.

Add two builds without a build-id that differ only in importing write()
or read(), a test that their fingerprints differ, and a test that
replaces the patched library with the other build at the same base and
checks that stopping leaves the replacement's slot alone. Link the test
libraries with the host page size as max-page-size: with aarch64's
default 64K alignment glibc over-allocates the mapping, a reload never
lands at the old base, and the reload tests always skipped there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Patching held _lock while dl_iterate_phdr() waited for glibc's
dl_load_write_lock. But dl_iterate_phdr() runs arbitrary callbacks
under that lock, and a callback that loads a library through the JVM,
a JNI System.loadLibrary() for instance, reaches Profiler::dlopen_hook()
and a synchronous refresh that waits for _lock. With a concurrent stop
or refresh holding _lock, each thread waited for the other forever.

Make dl_load_write_lock, then _lock, the only order. Patching and
unpatching now run in a LiveLibraryWalk, which takes _lock in the first
dl_iterate_phdr() callback, collects its candidates there and holds the
lock until it is destroyed. A refresh from inside a foreign callback
re-enters the recursive dl_load_write_lock and then waits for _lock,
whose holder waits for nothing. In turn, code under _lock must not wait
for dl_load_lock or dl_load_tls_lock, which a concurrent dlopen() or
dlclose() holds while waiting for dl_load_write_lock: none of it calls
dlopen(), dlsym(), dladdr() or dlinfo(), and the dlsym() calls of
patch_socket_functions() stay outside the walk. musl skips the walk and
just takes _lock.

Add a test that patches from inside a foreign dl_iterate_phdr() callback
while another thread stops socket patching. It deadlocks, and fails on
an alarm, with the old order.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@rkennke

rkennke commented Oct 6, 2026

Copy link
Copy Markdown
Contributor Author

Both findings confirmed and fixed:

[P1] Headers-only fingerprint — fixed in 92dc8f2. The fingerprint now also covers the import layout: every dynamic relocation that names a symbol (DT_JMPREL, DT_RELA/DT_REL), with its offset, type, symbol index and the symbol's name. Equal fingerprints at the same base now mean each saved slot address holds the same import, build-id or not. Regression tests use two real build-id-less DSOs that differ only in importing write() or read(): FingerprintCoversImports, and UnpatchDoesNotWriteIntoReplacementWithSameLayout, which is your scenario end to end (patch, unload, load the other build at the same base, stop). Both fail without the change; the latter reproduces the corruption.

[P1] Lock order — fixed in d8eeb61. The only order is now dl_load_write_lock, then _lock: patching and unpatching run in a LiveLibraryWalk that takes _lock in the first dl_iterate_phdr callback and holds it until the walk ends, so a refresh from inside a foreign callback just re-enters the recursive write lock. Code under _lock must not wait for dl_load_lock/dl_load_tls_lock; none of it calls dlopen/dlsym/dladdr/dlinfo or touches dynamic TLS. PatchInsideForeignWalkDoesNotDeadlockWithStop patches from inside a foreign callback while another thread stops socket patching; with the old order it deadlocks and fails on its alarm.

Verified on linux-aarch64 glibc 2.35 (container): all 75 gtestDebug binaries, libraryPatcherUnload_ut 26/26 without skips in debug and ASan, and testDebug -Ptests="*NativeSocket*". x64, musl and TSan are left to CI. The PR description is updated accordingly.

@dd-octo-sts

dd-octo-sts Bot commented Oct 6, 2026

Copy link
Copy Markdown
Contributor

Reliability & Chaos Results

✅ All reliability & chaos checks passed Pipeline: https://gitlab.ddbuild.io/DataDog/java-profiler/-/pipelines/142692549

@zhengyu123 zhengyu123 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@kaahos kaahos left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks good to me!

@rkennke
rkennke merged commit a60c7b1 into main Oct 6, 2026
242 of 243 checks passed
@rkennke
rkennke deleted the fix/prof-16135-stale-got-slots branch October 6, 2026 17:55
@github-actions github-actions Bot added this to the 1.52.0 milestone Oct 6, 2026
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.

3 participants