[PATCH v7 4/4] elf: Add LD_DEBUG=loadlock tracing for the dlclose destructor path (BZ 15686)

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

This is the follow-up to the LD_DEBUG=loadlock diagnostic ("elf: Add
LD_DEBUG=loadlock to trace dl_load_lock acquisitions"): it extends coverage
to the dlclose destructor path, complementing the dlopen constructor path
instrumented by the earlier change.  Together the two cover both halves of
the BZ #15686 deadlock class -- a destructor that (directly or via a spawned
thread) re-enters the loader and blocks on dl_load_lock is now as easy to
localise as the constructor case.

The DL_DEBUG_LOADLOCK mask bit and the _dl_debug_loadlock helper established
by the first commit are reused unchanged.  This commit adds the per-file
inlined trace_load_lock helper to elf/dl-close.c (identical to the one in
dl-open.c) and calls it at every dl_load_lock acquire/release site reachable
from _dl_close:

  * _dl_close entry acquire and the nodelete / not-open / done release
    paths.
  * The BZ #15686 release/reacquire around _dl_call_fini in
    _dl_close_worker (the direct mirror of dl_open_worker's release around
    the constructor), which is gated on the same
    glibc.rtld.strict_init_order tunable.

As on the constructor side, the backtrace is a manual frame-pointer walk
with no symbol resolution, so the trace can be emitted while dl_load_lock
is held without re-entering the loader; raw addresses are resolved offline
with addr2line.

The exit-time finalizer path in elf/dl-fini.c also takes dl_load_lock, but
that path is outside the BZ #15686 dlclose-destructor deadlock class and is
left uninstrumented for now.

No behaviour changes; the option is purely diagnostic.

Signed-off-by: Artem Proskurnev <[email protected]>
---
 elf/dl-close.c           | 25 +++++++++++++++++++++++--
 elf/tst-debug-loadlock.c | 14 +++++++++-----
 manual/dynlink.texi      | 17 +++++++++--------
 3 files changed, 41 insertions(+), 15 deletions(-)

diff --git a/elf/dl-close.c b/elf/dl-close.c
index 8e0b504a52..84e655fd7c 100644
--- a/elf/dl-close.c
+++ b/elf/dl-close.c
@@ -37,6 +37,17 @@
 
 #include <dl-unmap-segments.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);
+}
+
+
 /* Special l_idx value used to indicate which objects remain loaded.  */
 #define IDX_STILL_USED -1
 
@@ -317,12 +328,18 @@ _dl_close_worker (struct link_map *map, bool force)
 		   == 0);
 
 	      if (release_lock_for_fini)
-		__rtld_lock_unlock_recursive (GL (dl_load_lock));
+		{
+		  __rtld_lock_unlock_recursive (GL (dl_load_lock));
+		  trace_load_lock ("release", "_dl_close_worker(for-fini)");
+		}
 
 	      _dl_catch_exception (NULL, _dl_call_fini, imap);
 
 	      if (release_lock_for_fini)
-		__rtld_lock_lock_recursive (GL (dl_load_lock));
+		{
+		  __rtld_lock_lock_recursive (GL (dl_load_lock));
+		  trace_load_lock ("acquire", "_dl_close_worker(for-fini)");
+		}
 	    }
 
 #ifdef SHARED
@@ -820,6 +837,7 @@ _dl_close (void *_map)
   /* We must take the lock to examine the contents of map and avoid
      concurrent dlopens.  */
   __rtld_lock_lock_recursive (GL(dl_load_lock));
+  trace_load_lock ("acquire", "_dl_close");
 
   /* At this point we are guaranteed nobody else is touching the list of
      loaded maps, but a concurrent dlclose might have freed our map
@@ -830,6 +848,7 @@ _dl_close (void *_map)
     {
       /* Nope.  Do nothing.  */
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "_dl_close(nodelete)");
       return;
     }
 
@@ -846,10 +865,12 @@ _dl_close (void *_map)
   if (__builtin_expect (map->l_direct_opencount, 1) == 0)
     {
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
+      trace_load_lock ("release", "_dl_close(not-open)");
       _dl_signal_error (0, map->l_name, NULL, N_("shared object not open"));
     }
 
   _dl_close_worker (map, false);
 
   __rtld_lock_unlock_recursive (GL(dl_load_lock));
+  trace_load_lock ("release", "_dl_close(done)");
 }
diff --git a/elf/tst-debug-loadlock.c b/elf/tst-debug-loadlock.c
index 52623c097e..2ff72b4886 100644
--- a/elf/tst-debug-loadlock.c
+++ b/elf/tst-debug-loadlock.c
@@ -1,14 +1,15 @@
 /* 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.
+   Verifies that dl_load_lock acquisitions/releases around ELF constructor and
+   destructor execution (on the dlopen and dlclose paths) 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).
+   elf/dl-open.c, elf/dl-close.c).
 
    Copyright (C) 2026 Free Software Foundation, Inc.
    This file is part of the GNU C Library.
@@ -93,7 +94,8 @@ do_test (int argc, char *argv[])
   }
 
   /* 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.
+     acquire trace for both _dl_open (constructor path) and _dl_close
+     (destructor path), plus the BZ 15686 release-for-ctor/for-fini sites.
      Trace goes to stderr (dl_debug_fd defaults to STDERR_FILENO).  */
   setenv ("LD_DEBUG", "loadlock", 1);
   {
@@ -109,6 +111,8 @@ do_test (int argc, char *argv[])
       {
 	"dl_load_lock acquire at _dl_open",
 	"dl_open_worker(for-ctor)",
+	"dl_load_lock acquire at _dl_close",
+	"dl_close_worker(for-fini)",
       };
     for (int k = 0; k < (int) array_length (needles); k++)
       if (strstr (p.err.buffer, needles[k]) == NULL)
diff --git a/manual/dynlink.texi b/manual/dynlink.texi
index 5569830f23..43d84a7417 100644
--- a/manual/dynlink.texi
+++ b/manual/dynlink.texi
@@ -408,15 +408,16 @@ Display relocation statistics.
 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
+Log every acquisition and release of @code{dl_load_lock} around ELF constructor
+and destructor execution on the @code{dlopen} and @code{dlclose} paths, 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).
+code running inside an ELF constructor or destructor (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.
-- 
2.51.0