Skip to content

Commit b220402

Browse files
authored
fix: don't disable profiler on Alpine/musl over missing libgcc_s.so.1 (#720)
1 parent 9c2e173 commit b220402

2 files changed

Lines changed: 85 additions & 23 deletions

File tree

ddprof-lib/src/main/cpp/profiler.cpp

Lines changed: 37 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -845,12 +845,37 @@ void Profiler::writeHeapUsage(long value, bool live) {
845845

846846
bool Profiler::prewarmUnwinder() {
847847
#ifdef __linux__
848-
// J9 on aarch64 (and other JVMs) lazily loads libgcc_s.so.1 from its DWARF
849-
// unwinder during stack walks. When that happens inside a signal handler
850-
// frame, our dlopen_hook fires from signal context and tries to refresh the
851-
// library list — Mutex::lock and malloc on a signal stack. By forcing the
852-
// load here, before any signal handler is installed, subsequent calls find
853-
// libgcc_s already mapped and the lazy-load path never runs.
848+
// Force libgcc_s.so.1 to load now and report whether that succeeded. This
849+
// closes two separate lazy-load landmines that happen to share the same
850+
// library and the same fix:
851+
//
852+
// 1. J9's DWARF unwinder can lazily dlopen libgcc_s.so.1 while walking a
853+
// stack (e.g. on aarch64). Our sampling stack walks run from a signal
854+
// handler, so that dlopen call can fire our PLT-patched dlopen_hook()
855+
// from signal context, where it does Mutex::lock/malloc — both
856+
// AS-unsafe. Other JVM-internal lazy loads (observed under Graal on
857+
// aarch64) can trigger the same path. Forcing the load here, before any
858+
// signal handler is installed, means the JVM's later resolve finds
859+
// libgcc_s already mapped and this lazy-load path never runs.
860+
//
861+
// 2. Separately, glibc's pthread_exit()/pthread_cancel() call
862+
// __pthread_unwind(), which invokes _Unwind_ForcedUnwind()
863+
// unconditionally. glibc does not hard-link libgcc_s into
864+
// libc/libpthread; it lazily resolves that symbol via its own private
865+
// __libc_dlopen(LIBGCC_S_SO) the first time a thread exits or is
866+
// cancelled — including our own worker/sampler threads, whose
867+
// start_routine_wrapper (libraryPatcher_linux.cpp) calls pthread_exit().
868+
// If that lazy load fails, glibc itself calls __libc_fatal("libgcc_s.
869+
// so.1 must be installed for pthread_exit to work\n"), aborting the
870+
// whole process — observed in production on hardened/distroless images
871+
// that ship without libgcc_s.so.1.
872+
// __libc_dlopen is glibc's private loader, not the public PLT-visible
873+
// dlopen, so unlike (1) this does not route through dlopen_hook().
874+
//
875+
// Whether a load failure here is actually fatal is decided by the caller
876+
// (see checkState()): only landmine (2) is glibc-specific, so a musl host
877+
// missing libgcc_s.so.1 is not at risk and should not fail profiler
878+
// startup over it.
854879
//
855880
// The handle is intentionally leaked: keeping the refcount > 0 prevents the
856881
// library from being unmapped for the remainder of the process lifetime.
@@ -1325,9 +1350,12 @@ Error Profiler::checkState() {
13251350
if (s == ERROR) {
13261351
return Error("Profiler encountered fatal error");
13271352
} else if (s == NEW) {
1328-
// Force libgcc_s to load now (idempotent dlopen) so the JVM's DWARF
1329-
// unwinder cannot lazy-load it later from signal context.
1330-
if (!prewarmUnwinder()) {
1353+
// prewarmUnwinder() closes a glibc-specific pthread_exit/pthread_cancel
1354+
// landmine (see its comment) by force-loading libgcc_s.so.1 up front.
1355+
// A failure only matters on glibc, where that landmine is real; musl
1356+
// never hits the code path that needs libgcc_s, so a missing library
1357+
// there is not a reason to fail profiler startup.
1358+
if (!prewarmUnwinder() && !OS::isMusl()) {
13311359
_state.store(ERROR, std::memory_order_release);
13321360
return Error("Missing libgcc_s.so.1");
13331361
}

ddprof-lib/src/test/cpp/faultInjection_ut.cpp

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -243,13 +243,19 @@ class ScopedJvmtiMock {
243243
jvmtiEnv* _orig;
244244
};
245245

246-
// (d) Value-injection path: PROF-15395 fixed Profiler::checkState() (shared by
247-
// start()/check(), and therefore also reached by the -agentpath auto-start
248-
// path) to fail cleanly instead of crashing later when libgcc_s.so.1 can't be
249-
// loaded. libgcc_s.so.1 is always present in this test environment, so
250-
// INJECT_FAULT_BOOL_LIKELY on prewarmUnwinder()'s return value is what makes
251-
// that failure path reachable here: the real dlopen() still runs and
252-
// succeeds, but the caller is deterministically told it failed.
246+
// (d) Value-injection path: Profiler::checkState() (shared by start()/check(),
247+
// and therefore also reached by the -agentpath auto-start path) fails cleanly
248+
// instead of crashing later when libgcc_s.so.1 can't be loaded. libgcc_s.so.1
249+
// is always present in this test environment, so INJECT_FAULT_BOOL_LIKELY on
250+
// prewarmUnwinder()'s return value is what makes that failure path reachable
251+
// here: the real dlopen() still runs and succeeds, but the caller is
252+
// deterministically told it failed.
253+
//
254+
// checkState() only treats that failure as fatal off musl (see its comment:
255+
// musl never hits the pthread_exit path that needs libgcc_s.so.1), so on
256+
// musl an injected failure falls through to the mocked
257+
// JVMSupport::initialize() failure instead of surfacing "Missing
258+
// libgcc_s.so.1" -- expect whichever outcome the current libc implies.
253259
TEST_F(FaultInjectionTest, CheckStateSurfacesInjectedPrewarmUnwinderFailure) {
254260
#ifdef __linux__
255261
Profiler* p = Profiler::instance();
@@ -259,6 +265,7 @@ TEST_F(FaultInjectionTest, CheckStateSurfacesInjectedPrewarmUnwinderFailure) {
259265
ProfilerTestAccessor::setState(p, NEW);
260266
ProfiledThread::current()->setFiRng(0x5EED5EED5EED5EEDULL);
261267

268+
const bool prewarmFailureIsFatal = !OS::isMusl();
262269
bool sawInjectedFailure = false;
263270
bool sawNonInjectedPrewarm = false;
264271
// shouldFire() mixes the fixed RNG seed above with an ASLR-dependent
@@ -267,23 +274,50 @@ TEST_F(FaultInjectionTest, CheckStateSurfacesInjectedPrewarmUnwinderFailure) {
267274
// non-injected call is observed. Keep iterating (and un-latching the ERROR
268275
// state that every outcome here leaves behind) until both have been seen.
269276
for (int i = 0; i < 5000 && !(sawInjectedFailure && sawNonInjectedPrewarm); i++) {
277+
// shouldFire() increments FAULTS_INJECTED exactly once whenever it fires,
278+
// and the only fault-injection site reachable from checkState() is the
279+
// INJECT_FAULT_BOOL_LIKELY around prewarmUnwinder()'s dlopen() call --
280+
// JVMSupport::initialize() below is a plain mock, not an injection site.
281+
// So the counter delta across one checkState() call, not the returned
282+
// error message, is the ground truth for whether this iteration actually
283+
// took the injected path; that lets the two outcomes be set from
284+
// independent evidence instead of both being inferred from one string
285+
// compare.
286+
long long faultsBefore = Counters::getCounter(FAULTS_INJECTED);
270287
Error error = p->checkState();
288+
bool injectedThisCall = Counters::getCounter(FAULTS_INJECTED) > faultsBefore;
271289
ASSERT_TRUE((bool)error) << "checkState() must fail here: either the "
272290
"injected prewarmUnwinder() failure or the "
273291
"mocked JVMSupport::initialize() failure";
274-
if (std::strcmp(error.message(), "Missing libgcc_s.so.1") == 0) {
275-
sawInjectedFailure = true;
292+
if (prewarmFailureIsFatal) {
293+
if (injectedThisCall) {
294+
EXPECT_STREQ("Missing libgcc_s.so.1", error.message());
295+
sawInjectedFailure = true;
296+
} else {
297+
// prewarmUnwinder() succeeded (non-injected, ~99% of calls) and fell
298+
// through to the mocked JVMSupport::initialize() failure instead.
299+
EXPECT_STREQ("Profiler encountered fatal error", error.message());
300+
sawNonInjectedPrewarm = true;
301+
}
276302
} else {
277-
// prewarmUnwinder() succeeded (non-injected, ~99% of calls) and fell
278-
// through to the mocked JVMSupport::initialize() failure instead.
303+
// On musl, checkState() doesn't treat a prewarmUnwinder() failure as
304+
// fatal, so both an injected and a non-injected call fall through to
305+
// the same mocked JVMSupport::initialize() failure message -- only the
306+
// counter delta distinguishes them.
279307
EXPECT_STREQ("Profiler encountered fatal error", error.message());
280-
sawNonInjectedPrewarm = true;
308+
if (injectedThisCall) {
309+
sawInjectedFailure = true;
310+
} else {
311+
sawNonInjectedPrewarm = true;
312+
}
281313
}
282314
ProfilerTestAccessor::setState(p, NEW);
283315
}
284316

285-
EXPECT_TRUE(sawInjectedFailure)
286-
<< "expected at least one injected prewarmUnwinder() failure within 5000 tries";
317+
if (prewarmFailureIsFatal) {
318+
EXPECT_TRUE(sawInjectedFailure)
319+
<< "expected at least one injected prewarmUnwinder() failure within 5000 tries";
320+
}
287321
EXPECT_TRUE(sawNonInjectedPrewarm)
288322
<< "expected at least one non-injected prewarmUnwinder() success within 5000 tries";
289323
#endif // __linux__

0 commit comments

Comments
 (0)