[PATCH v7 3/4] elf: Add LD_DEBUG=loadlock to trace dl_load_lock acquisitions (BZ 15686)

[email protected] Mon, 3 Aug 2026 23:03:25 +0300
Newsgroups gmane.comp.lib.glibc.alpha
Message-ID <[email protected]>
From: Artem Proskurnev <[email protected]>

For BZ #15686 dynamic-loader
diagnostic that logs every dl_load_lock acquisition site together with a
backtrace, so that residual deadlocks of this shape -- code running inside
an ELF constructor (or a thread it spawns) that re-enters the loader and
blocks on dl_load_lock -- can be located without a debugger.  This adds
such a diagnostic, covering the dlopen constructor path.

A new LD_DEBUG keyword, "loadlock", enables a new dl_debug_mask bit,
DL_DEBUG_LOADLOCK.  While active, each acquire/release of dl_load_lock on
the _dl_open path prints "dl_load_lock <action> at <site>" followed by a
return-address backtrace.  This commit instruments the constructor side
(elf/dl-open.c); the destructor side (elf/dl-close.c) will be handled in
a follow-up.

The backtrace is obtained by walking the frame-pointer chain directly via
__builtin_frame_address.  This is deliberately lock-free, allocation-free
and syscall-free: the trace may be emitted while dl_load_lock is held, so
the libc backtrace() helper and any symbol resolution are off limits --
both would re-enter the dynamic loader (dl_iterate_phdr / _dl_addr) and
either recurse or deadlock against dl_load_lock itself.  Raw code
addresses are printed and resolved offline with addr2line.  Frame
pointers must be present in the code being traced, which is the default
for the dynamic linker on the targets of interest; when they are absent
the walk simply terminates early and only the site header is printed.

No behaviour changes; the option is purely diagnostic.

Signed-off-by: Artem Proskurnev <[email protected]>
---
 elf/Makefile                 |   5 ++
 elf/dl-debug.c               |  42 ++++++++++++
 elf/dl-open.c                |  32 ++++++++-
 elf/rtld.c                   |   2 +
 elf/tst-debug-loadlock-mod.c |   4 ++
 elf/tst-debug-loadlock.c     | 127 +++++++++++++++++++++++++++++++++++
 manual/dynlink.texi          |  11 +++
 sysdeps/generic/ldsodefs.h   |  12 ++++
 8 files changed, 233 insertions(+), 2 deletions(-)
 create mode 100644 elf/tst-debug-loadlock-mod.c
 create mode 100644 elf/tst-debug-loadlock.c

diff --git a/elf/Makefile b/elf/Makefile
index 94c5b7e6ed..5553cecbb5 100644
--- a/elf/Makefile
+++ b/elf/Makefile
@@ -432,6 +432,7 @@ tests += \
   tst-big-note \
   tst-bz26577 \
   tst-bz26577-minstack \
+  tst-debug-loadlock \
   tst-debug1 \
   tst-deep1 \
   tst-dl-is_dso \
@@ -959,6 +960,7 @@ modules-names += \
   tst-auditmod9b \
   tst-auxvalmod \
   tst-big-note-lib \
+  tst-debug-loadlock-mod \
   tst-deep1mod1 \
   tst-deep1mod2 \
   tst-deep1mod3 \
@@ -2891,6 +2893,9 @@ $(objpfx)tst-nodelete-dlclose.out: $(objpfx)tst-nodelete-dlclose-dso.so \
 
 $(objpfx)tst-debug1.out: $(objpfx)tst-debug1mod1.so
 
+$(objpfx)tst-debug-loadlock.out: $(objpfx)tst-debug-loadlock-mod.so
+tst-debug-loadlock-ARGS = -- $(host-test-program-cmd)
+
 $(objpfx)tst-debug1mod1.so: $(objpfx)testobj1.so
 	$(OBJCOPY) --only-keep-debug $< $@
 
diff --git a/elf/dl-debug.c b/elf/dl-debug.c
index 3105bad93a..bca5c2a407 100644
--- a/elf/dl-debug.c
+++ b/elf/dl-debug.c
@@ -167,3 +167,45 @@ _dl_debug_initialize (ElfW(Addr) ldbase, Lmid_t ns)
 
   return &r->base;
 }
+
+/* Log a dl_load_lock acquisition/release site together with a raw-address
+   backtrace.  Called only when the DL_DEBUG_LOADLOCK mask is set.
+
+   The backtrace is obtained by walking the frame-pointer chain directly.
+   This is deliberately lock-free, allocation-free and syscall-free: we may
+   be running inside an _dl_load_lock critical section, so we cannot call
+   the libc backtrace() helper or perform any symbol resolution (both would
+   re-enter the dynamic linker and either recurse or deadlock against
+   dl_load_lock itself).  Resolve the printed addresses offline with
+   addr2line(1).  Frame pointers must be present in the code being traced
+   (the default for the dynamic linker on most targets).  */
+void
+_dl_debug_loadlock (const char *action, const char *site)
+{
+  _dl_debug_printf ("dl_load_lock %s at %s\n", action, site);
+
+  struct layout
+  {
+    struct layout *next;
+    void *ret;
+  };
+  struct layout *p = (struct layout *) __builtin_frame_address (0);
+  /* Bound the frame-pointer walk to the current stack so a chain that runs
+     into frame-pointer-less code (e.g. -O2 libc, which does not set up rbp)
+     terminates instead of dereferencing garbage.  The stack grows down, so
+     caller frames sit at strictly higher addresses: require a monotonic,
+     in-range, aligned advance.  No symbol resolution is performed (it would
+     re-enter the loader and deadlock on dl_load_lock); resolve the printed
+     addresses offline with addr2line(1).  */
+  uintptr_t start = (uintptr_t) p;
+  for (int n = 0; p != NULL && n < 32; ++n)
+    {
+      _dl_debug_printf ("  #%d  0x%lx\n", n, (unsigned long int) p->ret);
+      uintptr_t naddr = (uintptr_t) p->next;
+      if (naddr <= (uintptr_t) p
+	  || naddr - start > (1u << 20)
+	  || (naddr & 7) != 0)
+	break;
+      p = p->next;
+    }
+}
diff --git a/elf/dl-open.c b/elf/dl-open.c
index 9930371867..86fa3cfcfb 100644
--- a/elf/dl-open.c
+++ b/elf/dl-open.c
@@ -43,6 +43,17 @@
 #include <dl-prop.h>
 
 
+/* When LD_DEBUG=loadlock is active, log a dl_load_lock acquire/release site
+   together with a raw-address backtrace (see _dl_debug_loadlock).  Inlined so
+   the hot path costs only a single mask test when the flag is off.  */
+static inline void
+trace_load_lock (const char *action, const char *site)
+{
+  if (__glibc_unlikely (GLRO (dl_debug_mask) & DL_DEBUG_LOADLOCK))
+    _dl_debug_loadlock (action, site);
+}
+
+
 /* We must be careful not to leave us in an inconsistent state.  Thus we
    catch any error and re-raise it after cleaning up.  */
 
@@ -644,6 +655,8 @@ dl_open_worker_begin (void *a)
 	    {
 	      __rtld_lock_unlock_recursive (GL(dl_load_tls_lock));
 	      __rtld_lock_unlock_recursive (GL(dl_load_lock));
+	      trace_load_lock ("release",
+			       "dl_open_worker_begin(already-loaded)");
 	    }
 
 	  while (atomic_load_acquire (&new->l_init_once) != 2)
@@ -652,6 +665,8 @@ dl_open_worker_begin (void *a)
 	  if (unlock_for_ctor)
 	    {
 	      __rtld_lock_lock_recursive (GL(dl_load_lock));
+	      trace_load_lock ("acquire",
+			       "dl_open_worker_begin(already-loaded)");
 	      __rtld_lock_lock_recursive (GL(dl_load_tls_lock));
 	    }
 	}
@@ -879,7 +894,10 @@ dl_open_worker (void *a)
   atomic_store_release (&new->l_init_pending, 1);
 
   if (release_lock_for_ctor)
-    __rtld_lock_unlock_recursive (GL(dl_load_lock));
+    {
+      __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "dl_open_worker(for-ctor)");
+    }
 
   /* Run the initializer functions of new objects.  Temporarily
      disable the exception handler, so that lazy binding failures are
@@ -889,7 +907,10 @@ dl_open_worker (void *a)
   /* Re-acquire dl_load_lock for the final global scope update and
      for the lock/unlock pairing expected by _dl_open.  */
   if (release_lock_for_ctor)
-    __rtld_lock_lock_recursive (GL(dl_load_lock));
+    {
+      __rtld_lock_lock_recursive (GL(dl_load_lock));
+      trace_load_lock ("acquire", "dl_open_worker(for-ctor)");
+    }
 
   /* Now we can make the new map available in the global scope.  */
   if (mode & RTLD_GLOBAL)
@@ -911,6 +932,7 @@ _dl_open (const char *file, int mode, const void *caller_dlopen, Lmid_t nsid,
 
   /* Make sure we are alone.  */
   __rtld_lock_lock_recursive (GL(dl_load_lock));
+  trace_load_lock ("acquire", "_dl_open");
 
   if (__glibc_unlikely (nsid == LM_ID_NEWLM))
     {
@@ -923,6 +945,7 @@ _dl_open (const char *file, int mode, const void *caller_dlopen, Lmid_t nsid,
 	{
 	  /* No more namespace available.  */
 	  __rtld_lock_unlock_recursive (GL(dl_load_lock));
+	  trace_load_lock ("release", "_dl_open(no-namespace)");
 
 	  _dl_signal_error (EINVAL, file, NULL, N_("\
 no more namespaces available for dlmopen()"));
@@ -1011,14 +1034,17 @@ no more namespaces available for dlmopen()"));
 	  && map->l_init_owner != THREAD_GETMEM (THREAD_SELF, tid))
 	{
 	  __rtld_lock_unlock_recursive (GL(dl_load_lock));
+	  trace_load_lock ("release", "_dl_open(fast-path wait)");
 
 	  while (atomic_load_acquire (&map->l_init_once) != 2)
 	    lll_futex_wait (&map->l_init_once, 1, LLL_PRIVATE);
 
 	  __rtld_lock_lock_recursive (GL(dl_load_lock));
+	  trace_load_lock ("acquire", "_dl_open(fast-path wait)");
 	}
 
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "_dl_open(fast-path return)");
       return map;
     }
 
@@ -1059,6 +1085,7 @@ no more namespaces available for dlmopen()"));
 
       /* Release the lock.  */
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "_dl_open(error cleanup)");
 
       /* Reraise the error.  */
       _dl_signal_exception (errcode, &exception, NULL);
@@ -1070,6 +1097,7 @@ no more namespaces available for dlmopen()"));
 
   /* Release the lock.  */
   __rtld_lock_unlock_recursive (GL(dl_load_lock));
+  trace_load_lock ("release", "_dl_open(done)");
 
   return args.map;
 }
diff --git a/elf/rtld.c b/elf/rtld.c
index fc053df858..df3e70eee6 100644
--- a/elf/rtld.c
+++ b/elf/rtld.c
@@ -2438,6 +2438,8 @@ process_dl_debug (struct dl_main_state *state, const char *dl_debug)
 	DL_DEBUG_STATISTICS },
       { LEN_AND_STR ("unused"), "determined unused DSOs",
 	DL_DEBUG_UNUSED },
+      { LEN_AND_STR ("loadlock"), "log dl_load_lock acquire/release sites",
+	DL_DEBUG_LOADLOCK },
       { LEN_AND_STR ("help"), "display this help message and exit",
 	DL_DEBUG_HELP },
     };
diff --git a/elf/tst-debug-loadlock-mod.c b/elf/tst-debug-loadlock-mod.c
new file mode 100644
index 0000000000..dbffd9a39a
--- /dev/null
+++ b/elf/tst-debug-loadlock-mod.c
@@ -0,0 +1,4 @@
+/* Loadable module for elf/tst-debug-loadlock.c.
+   No constructor is needed: the dl_load_lock acquire/release traces fire
+   on every dlopen regardless of whether the target runs initializers.  */
+int tst_debug_loadlock_mod_variable = 1;
diff --git a/elf/tst-debug-loadlock.c b/elf/tst-debug-loadlock.c
new file mode 100644
index 0000000000..52623c097e
--- /dev/null
+++ b/elf/tst-debug-loadlock.c
@@ -0,0 +1,127 @@
+/* Test for LD_DEBUG=loadlock.
+   Verifies that dl_load_lock acquisitions/releases on the dlopen constructor
+   path are logged with a backtrace when LD_DEBUG=loadlock is active, and that
+   "loadlock" appears in LD_DEBUG=help output.
+
+   The dl_debug_mask is set by rtld only at process startup, so both checks
+   re-exec this binary as a child (under the freshly built ld.so, via
+   $(host-test-program-cmd) passed in tst-debug-loadlock-ARGS) with LD_DEBUG
+   set in the child environment, and capture the child's std streams.  The
+   trace lines themselves are emitted by the dynamic linker (elf/dl-debug.c,
+   elf/dl-open.c).
+
+   Copyright (C) 2026 Free Software Foundation, Inc.
+   This file is part of the GNU C Library.
+
+   The GNU C Library is free software; you can redistribute it and/or
+   modify it under the terms of the GNU Lesser General Public
+   License as published by the Free Software Foundation; either
+   version 2.1 of the License, or (at your option) any later version.
+
+   The GNU C Library is distributed in the hope that it will be useful,
+   but WITHOUT ANY WARRANTY; without even the implied warranty of
+   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+   Lesser General Public License for more details.
+
+   You should have received a copy of the GNU Lesser General Public
+   License along with the GNU C Library; if not, see
+   <https://www.gnu.org/licenses/>.  */
+
+#include <dlfcn.h>
+#include <getopt.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#include <array_length.h>
+#include <support/capture_subprocess.h>
+#include <support/check.h>
+#include <support/support.h>
+
+/* Child mode (--restart): perform the dlopen whose dl_load_lock sites we want
+   rtld to trace, then exit.  */
+static int restart;
+#define CMDLINE_OPTIONS \
+  { "restart", no_argument, &restart, 1 },
+
+static int
+handle_restart (void)
+{
+  void *h = dlopen ("tst-debug-loadlock-mod.so", RTLD_LAZY);
+  if (h == NULL)
+    {
+      fprintf (stderr, "dlopen failed: %s\n", dlerror ());
+      _exit (1);
+    }
+  dlclose (h);
+  _exit (0);
+}
+
+static int
+do_test (int argc, char *argv[])
+{
+  if (restart)
+    return handle_restart ();
+
+  /* Re-exec ourselves under the freshly built ld.so.  argv[1..] holds the
+     ld.so invocation prefix (the tokens of $(host-test-program-cmd)) supplied
+     via tst-debug-loadlock-ARGS; support_test_main has already stripped the
+     leading "--" separator used to terminate option parsing.  */
+  char *spargv[argc + 2];
+  int i = 0;
+  for (; i < argc - 1; i++)
+    spargv[i] = argv[i + 1];
+  spargv[i] = NULL;
+
+  /* Check 1: LD_DEBUG=help lists "loadlock".  rtld prints the table (via
+     _dl_printf, i.e. to stdout) and _exit()s before main runs, so the child
+     need not (and will not) reach handle_restart.  */
+  setenv ("LD_DEBUG", "help", 1);
+  {
+    struct support_capture_subprocess p
+      = support_capture_subprogram (spargv[0], spargv, NULL);
+    support_capture_subprocess_check (&p, "tst-debug-loadlock (help)", 0,
+				      sc_allow_stdout);
+    if (strstr (p.out.buffer, "loadlock") == NULL)
+      {
+	support_record_failure ();
+	printf ("LD_DEBUG=help stdout was:\n%s\n", p.out.buffer);
+	FAIL_EXIT1 ("'loadlock' missing from LD_DEBUG=help output");
+      }
+    support_capture_subprocess_free (&p);
+  }
+
+  /* Check 2: a real dlopen with LD_DEBUG=loadlock emits the dl_load_lock
+     acquire trace for _dl_open and the BZ 15686 release-for-ctor site.
+     Trace goes to stderr (dl_debug_fd defaults to STDERR_FILENO).  */
+  setenv ("LD_DEBUG", "loadlock", 1);
+  {
+    spargv[i++] = (char *) "--restart";
+    spargv[i] = NULL;
+    struct support_capture_subprocess p
+      = support_capture_subprogram (spargv[0], spargv, NULL);
+    support_capture_subprocess_check (&p, "tst-debug-loadlock (loadlock)", 0,
+				      sc_allow_stderr);
+    unsetenv ("LD_DEBUG");
+
+    static const char *const needles[] =
+      {
+	"dl_load_lock acquire at _dl_open",
+	"dl_open_worker(for-ctor)",
+      };
+    for (int k = 0; k < (int) array_length (needles); k++)
+      if (strstr (p.err.buffer, needles[k]) == NULL)
+	{
+	  support_record_failure ();
+	  printf ("LD_DEBUG=loadlock stderr was:\n%s\n", p.err.buffer);
+	  FAIL_EXIT1 ("'%s' missing from trace", needles[k]);
+	}
+    support_capture_subprocess_free (&p);
+  }
+
+  return 0;
+}
+
+#define TEST_FUNCTION_ARGV do_test
+#include <support/test-driver.c>
diff --git a/manual/dynlink.texi b/manual/dynlink.texi
index ad4da753a5..5569830f23 100644
--- a/manual/dynlink.texi
+++ b/manual/dynlink.texi
@@ -407,6 +407,17 @@ Display relocation statistics.
 @item unused
 Determined unused DSOs.
 
+@item loadlock
+Log every acquisition and release of @code{dl_load_lock} on the @code{dlopen}
+constructor path, each followed by a raw return-address backtrace.  Use this to
+diagnose deadlocks of the shape described in
+@uref{https://sourceware.org/bugzilla/show_bug.cgi?id=15686, BZ 15686}, where
+code running inside an ELF constructor (or a thread it spawns) re-enters the
+dynamic linker and blocks on @code{dl_load_lock}.  The backtrace lists raw code
+addresses; resolve them offline with @command{addr2line}.  Frame pointers must
+be present in the code being traced (the default for the dynamic linker on most
+targets).
+
 @item help
 Display a help message with all available options and exit.
 @end table
diff --git a/sysdeps/generic/ldsodefs.h b/sysdeps/generic/ldsodefs.h
index c0deb11c02..d617864efd 100644
--- a/sysdeps/generic/ldsodefs.h
+++ b/sysdeps/generic/ldsodefs.h
@@ -533,6 +533,10 @@ struct rtld_global_ro
 #define DL_DEBUG_HELP       (1 << 10)
 #define DL_DEBUG_TLS        (1 << 11)
 #define DL_DEBUG_SECURITY   (1 << 12)
+/* Trace dl_load_lock acquisitions (with a raw-address backtrace) to help
+   diagnose deadlocks where code running inside a dlopen constructor (or a
+   thread it spawns) re-enters the dynamic linker.  */
+#define DL_DEBUG_LOADLOCK   (1 << 13)
 
   /* Platform name.  */
   EXTERN const char *_dl_platform;
@@ -766,6 +770,14 @@ extern void _dl_debug_printf (const char *fmt, ...)
 extern void _dl_debug_printf_c (const char *fmt, ...)
      __attribute__ ((__format__ (__printf__, 1, 2))) attribute_hidden;
 
+/* Print "dl_load_lock <action> at <site>" followed by a raw-address
+   backtrace obtained by walking the frame-pointer chain.  The caller must
+   have already checked the DL_DEBUG_LOADLOCK mask.  No symbol resolution
+   is performed (it would re-enter the loader and deadlock on
+   dl_load_lock); resolve the printed addresses offline with addr2line.  */
+extern void _dl_debug_loadlock (const char *action, const char *site)
+     attribute_hidden;
+
 
 /* Write a message on the specified descriptor FD.  The parameters are
    interpreted as for a `printf' call.  */
-- 
2.51.0