[PATCH v3] elf: Release dl_load_lock before running dlopen constructors (BZ 15686)

temap@mail.ru temap@mail.ru
Thu Jul 16 16:04:01 GMT 2026


From: Artem Proskurnev <temap@mail.ru>

This addresses one instance of the long-standing class of deadlocks
described in BZ #15686: ELF constructors and destructors invoked by
the dynamic loader run with dl_load_lock held, so any code path in
those constructors that itself needs dl_load_lock deadlocks.

dl_open_worker holds dl_load_lock across the entire _dl_open call,
including the call to call_dl_init that runs the new objects'
constructors.  If one of those constructors spawns a thread whose
first access to a thread_local object triggers
__cxa_thread_atexit_impl, the new thread blocks trying to acquire
dl_load_lock -- which is held by the dlopen thread -- deadlocking
the process.  The same deadlock arises when the spawned thread calls
a function that triggers NSS module loading through _dl_open, or any
other code path that needs dl_load_lock.

The blocking site is __cxa_thread_atexit_impl at
stdlib/cxa_thread_atexit_impl.c.  BZ #28357 was a partial fix for
the wider BZ #15686 problem: it moved dl_open_worker_begin and
_dl_close_worker to the finer-grained dl_load_tls_lock (commit
024a7640ab) and used that new lock in pthread_create and
__tls_get_addr.  __cxa_thread_atexit_impl, however, still takes
dl_load_lock to protect its DSO lookup (_dl_find_dso_for_object)
against a racing dlclose, and that path is not covered by the
BZ #28357 fix.  Moving it to dl_load_tls_lock is not straightforward
because _dl_find_dso_for_object walks _ns_loaded, which is protected
by dl_load_lock rather than dl_load_tls_lock.

This patch takes the alternative approach of releasing dl_load_lock
during constructor execution.  At the point where call_dl_init runs,
the following invariants hold:

  * All link_map structures for the newly loaded DSO and its
    dependencies are fully initialized and immutable.
  * The DSO has l_direct_opencount == 1 (incremented in
    dl_open_worker_begin), so a concurrent dlclose cannot unload it:
    _dl_close_worker short-circuits when the count is non-zero.
  * Implicit dependencies are protected by the l_map_used marking in
    _dl_close_worker, which transitively marks the l_initfini chain
    of any map with non-zero l_direct_opencount.
  * dl_iterate_phdr uses dl_load_write_lock rather than dl_load_lock
    and is unaffected by the unlock.  Other threads calling
    dl_iterate_phdr during the constructor may observe the DSO before
    its constructor has run; this is consistent with POSIX, which
    does not guarantee atomic appearance of dlopen'd objects, and is
    equivalent to dlsym from inside a constructor observing
    partially-initialized main-executable symbols.
  * Recursive dlopen from a constructor re-acquires dl_load_lock
    normally in _dl_open and proceeds serially.

The lock is re-acquired immediately after constructors complete,
before add_to_global_update and the lock/unlock pairing expected by
_dl_open.

Exception safety: call_dl_init is invoked via
_dl_catch_exception (NULL, ...) so that lazy binding failures are
fatal (the process exits through _dl_fatal_printf); therefore the
re-lock is not required on the error path.  C++ exceptions thrown
from constructors are a separate, pre-existing concern: dl exception
handling uses setjmp/longjmp rather than C++ unwinding, so a thrown
exception may leave locks in any state regardless of this patch.
Releasing the lock is strictly safer than holding it in that case.

Minimal reproducer: a DSO whose constructor calls
gdk_pixbuf_new_from_file on a system where the glycin image loader
is wired in via gdk-pixbuf reaches a sandboxed loader process spawn,
which in turn calls std::thread::spawn; the spawned thread's first
thread_local access (__cxa_thread_atexit_impl) blocks on dl_load_lock
held by the dlopen caller.  The same hang reproduces with any
constructor that spawns a thread touching thread_local state or
triggering NSS module loads.

A regression test is added in sysdeps/pthread/tst-create2.c with its
DSO in tst-create2mod.c.  The DSO constructor spawns a worker thread
that calls __cxa_thread_atexit_impl and then joins it; under the
pre-fix locking model the join deadlocks and the test framework
times out.  The test follows the layout of tst-create1 (BZ #28357),
which covers the pthread_create leg of the same bug class.

Releasing the lock introduces a data race on l_init_called in
call_init (elf/dl-init.c): two threads performing concurrent dlopen
of the same DSO could both pass the l_init_called check and run the
constructor in parallel.  This is addressed by adding per-DSO init
serialisation using an l_init_once field in struct link_map.  call_init
uses atomic_compare_and_exchange_bool_acq to claim the right to run
the constructor (0 -> 1); the winning thread proceeds, while losing
threads block via lll_futex_wait (or __lll_wait on Hurd) until the
winner signals completion (1 -> 2) via lll_futex_wake (or __lll_wake).
Different DSOs can initialise concurrently because each has its own
l_init_once.  The CAS / futex_wait / futex_wake dance is only needed
when other threads may be waiting, so single-threaded processes skip
it via RTLD_SINGLE_THREAD_P - but l_init_once is always set to 2
after the constructor finishes, so a later call_init from another
thread (e.g. via a transitive dlopen once the process goes
multi-threaded) for a DSO initialised at startup does not wait on a
value that was never going to change.  Only the futex wake is
conditional on RTLD_SINGLE_THREAD_P; without the unconditional store
this bug hung intl/tst-gettext4 and tst-gettext5 in the full make
check, because ld.so and libc.so initialise single-threaded at
startup but later receive transitive call_init from gconv/NSS loads.

A second field, l_init_owner, records the kernel TID of the thread
that won the l_init_once CAS and is currently running the constructor.
It serves two purposes.  First, it closes a return-before-completion
race: l_init_called is set at the top of call_init, before the
constructor runs, so a concurrent caller that arrives while the
constructor is still in progress sees l_init_called set and returns
immediately -- even though l_init_once is still 1 and the
constructor's writes have not yet been published.  With l_init_owner,
such a caller compares l_init_owner against its own TID; if they
differ, it waits on l_init_once via futex until the winner signals
completion.  Second, l_init_owner lets a recursive call_init that
originates from inside the constructor itself (e.g. via a transitive
dlopen of a circular dependency) recognise itself and return
immediately, instead of waiting for its own constructor and
deadlocking.

A third field, l_init_pending, closes a narrow race window that
l_init_owner alone does not cover.  l_init_called is set only inside
call_init, which runs after dl_load_lock has been released and after
the CAS has been won; a concurrent dlopen
caller that takes the already-loaded early-return path (in
dl_open_worker_begin when new->l_searchlist.r_list != NULL, or in
_dl_open via is_already_fully_open) could observe l_init_called == 0
and l_init_once == 0 and return before the constructor has been
scheduled, defeating the wait that l_init_owner was meant to enforce.
l_init_pending is set to 1 in dl_open_worker while it still holds
dl_load_lock, just before releasing it to run the constructor,
and is cleared by call_init once it has won the l_init_once CAS and
become the init owner.  Both early-return paths then wait on
l_init_once whenever (l_init_pending || l_init_called) is set,
l_init_once != 2, and l_init_owner differs from the current TID,
releasing dl_load_lock around the wait.

Finally, the _dl_open fast path (is_already_fully_open) was tightened
to increment l_direct_opencount BEFORE entering the wait loop, matching
the existing increment in dl_open_worker_begin.  An earlier version
of this patch incremented it afterwards.  Without the increment first,
the loader thread that ran the constructor could complete, dlsym the
DSO, dlclose it (driving opencount to 0 and unloading the DSO), and
free the link_map while other threads were still blocked in the wait
loop - a use-after-free that surfaced in tst-create3 as segfaults in
do_lookup_x with a poisoned scope pointer (0x2a2a2a2a2a2a2a2a) on
cleanup, after the test had already printed PASS.

A second regression test, tst-create3/tst-create3mod, exercises
concurrent dlopen of the same DSO with NTHREADS=8 callers and
verifies two invariants:

  1. The constructor runs EXACTLY ONCE.  This catches the case where
     two threads both think they lost the l_init_once CAS but
     proceed anyway.
  2. No dlopen caller returns before the constructor has finished.
     The constructor sleeps 200 ms and publishes a "done" magic as
     its final write; each caller checks the magic immediately after
     dlopen returns.  This catches the l_init_called short-circuit
     race that l_init_owner closes -- an earlier version of the test
     that only checked ctor_count == 1 did NOT catch it.

A third test, tst-create4/tst-create4mod-a/tst-create4mod-b,
documents a property that the patch removes: under the pre-BZ-15686
locking model, dl_load_lock serialised the entire _dl_open across
threads, so constructors of independent DSOs loaded from different
threads could not interleave.  After the patch, they can.  The test
asserts the OLD non-interleaving behaviour and is therefore marked
XFAIL via test-xfail-tst-create4 = yes.  It is included as a
diagnostic tool for downstream applications that may have implicitly
relied on the old total order -- plugin registries whose registration
order determined behaviour, signal-handler chains installed in
constructors, global logger/telemetry setup, etc.

A fourth regression test, tst-create5, checks a subtle regression in
the main-executable path of the BZ 15686 fix.  The main executable's
constructors are run by the startup code rather than by call_init, so
an earlier version of this patch left l_init_once at 0 for the
executable.  A later multi-threaded dlopen(NULL) / __RTLD_OPENEXEC
then took the already-loaded early-return path in dl_open_worker_begin,
saw l_init_called == 1 but l_init_once != 2, and waited forever for a
constructor that would never complete.  tst-create5 spawns a worker
thread that calls dlopen(NULL, RTLD_NOW); without the fix the call
deadlocks and the test-driver times out, while the fix (setting
l_init_once = 2 for the executable in call_init, with a futex wake when
multi-threaded) makes dlopen(NULL) return immediately.

To aid diagnosis of such applications, a new tunable
glibc.rtld.strict_init_order (default 0) reverts to the pre-BZ-15686
locking model: dl_load_lock is held across constructor execution,
giving the old strict total order of dlopen calls across threads at
the cost of reintroducing the BZ 15686 deadlock.  The tunable is
intended as a temporary escape hatch, not a long-term solution, and
is documented in manual/tunables.texi.  It is enabled at run time
through GLIBC_TUNABLES, e.g.:

  GLIBC_TUNABLES=glibc.rtld.strict_init_order=1 ./your-app

Because the tunable is evaluated in dl_open_worker on every dlopen,
it can be set on a per-process basis without rebuilding, and cleared
again once the downstream application has been fixed.

Tested on x86_64-linux-gnu.  Both directions of the main regression
test verified: sysdeps/pthread/tst-create2 deadlocks (times out
after 10 s) on the unpatched tree and passes (exit 0) with this
patch.  tst-create3 covers both the ctor-runs-once invariant and the
visibility invariant that l_init_owner + l_init_pending preserve,
and ran 15/15 stable once the opencount-before-wait fix was applied
to the _dl_open fast path.  tst-create4 is marked XFAIL because it
asserts the non-interleaving property the patch intentionally removes
(constructors of independent DSOs loaded from different threads can
now overlap); flip the XFAIL off to chase ordering-dependent
regressions in downstream applications.
tst-create5 was checked by building the rest of this patch without the
main-executable fix: that intermediate build fails nptl/tst-create5
(timeout), while the full patch passes it.

The real-world trigger was also verified end-to-end with a minimal
reproducer: a DSO whose constructor calls gdk_pixbuf_new_from_file
on a PNG, reaching the glycin sandbox loader via gdk-pixbuf and
spawning a Rust std::thread whose first thread_local access hits
__cxa_thread_atexit_impl.  Against the unpatched tree the reproducer
hangs (timeout 10 s); against the patched tree it loads the image
and exits 0.

Full glibc test suite (make check): the only difference between the
intermediate build (without the main-executable fix) and the final
patched tree is nptl/tst-create5, which moves from FAIL to PASS.  In
the test runs used here the intermediate build produced 6877 PASS / 8
FAIL and the final tree produced 6878 PASS / 7 FAIL.  All seven
remaining FAILs on the patched tree are environmental (missing
capabilities or test-root permissions at install time) and reproduce
identically on the intermediate build.  The expected output of
elf/tst-rtld-list-tunables was updated for the new
glibc.rtld.strict_init_order line.

Co-authored-by: Alexander Pevzner <pzz@apevzner.com>
Signed-off-by: Artem Proskurnev <temap@mail.ru>
Signed-off-by: Alexander Pevzner <pzz@apevzner.com>
---
 elf/dl-init.c                      |  99 ++++++++++++++++++-
 elf/dl-open.c                      | 117 +++++++++++++++++++++-
 elf/dl-tunables.list               |   6 ++
 elf/tst-rtld-list-tunables.exp     |   1 +
 include/link.h                     |  22 +++++
 manual/tunables.texi               |  29 ++++++
 sysdeps/pthread/Makefile           |  36 +++++++
 sysdeps/pthread/tst-create2.c      |  48 +++++++++
 sysdeps/pthread/tst-create2mod.c   |  62 ++++++++++++
 sysdeps/pthread/tst-create3.c      | 123 +++++++++++++++++++++++
 sysdeps/pthread/tst-create3mod.c   |  73 ++++++++++++++
 sysdeps/pthread/tst-create4.c      | 152 +++++++++++++++++++++++++++++
 sysdeps/pthread/tst-create4mod-a.c |  39 ++++++++
 sysdeps/pthread/tst-create4mod-b.c |  39 ++++++++
 sysdeps/pthread/tst-create5.c      |  63 ++++++++++++
 15 files changed, 903 insertions(+), 6 deletions(-)
 create mode 100644 sysdeps/pthread/tst-create2.c
 create mode 100644 sysdeps/pthread/tst-create2mod.c
 create mode 100644 sysdeps/pthread/tst-create3.c
 create mode 100644 sysdeps/pthread/tst-create3mod.c
 create mode 100644 sysdeps/pthread/tst-create4.c
 create mode 100644 sysdeps/pthread/tst-create4mod-a.c
 create mode 100644 sysdeps/pthread/tst-create4mod-b.c
 create mode 100644 sysdeps/pthread/tst-create5.c

diff --git a/elf/dl-init.c b/elf/dl-init.c
index bd85bacdc1..072f71e237 100644
--- a/elf/dl-init.c
+++ b/elf/dl-init.c
@@ -20,6 +20,25 @@
 #include <stddef.h>
 #include <ldsodefs.h>
 #include <elf-initfini.h>
+#include <tls.h>
+
+/* Per-DSO once-initialization for constructor execution.
+   l_init_once is an int used as a low-level lock (LLL):
+   0 = uninitialized, 1 = initializing, 2 = initialized.
+   The lock is always private to the process.  */
+
+/* Platform-specific wait/wake primitives for once-initialization.  */
+#ifdef __linux__
+# define DL_INIT_ONCE_WAIT(futexp, val, private) \
+   lll_futex_wait (futexp, val, private)
+# define DL_INIT_ONCE_WAKE(futexp, nr, private) \
+   lll_futex_wake (futexp, nr, private)
+#else
+# define DL_INIT_ONCE_WAIT(futexp, val, private) \
+   __lll_wait (futexp, val, private)
+# define DL_INIT_ONCE_WAKE(futexp, nr, private) \
+   __lll_wake (futexp, private)
+#endif
 
 
 static void
@@ -35,8 +54,31 @@ call_init (struct link_map *l, int argc, char **argv, char **env)
   assert (l->l_relocated || l->l_type == lt_executable);
 
   if (l->l_init_called)
-    /* This object is all done.  */
-    return;
+    {
+      /* call_init has already been invoked for this map.  In a
+         single-threaded context this means the constructor has run
+         (or we are inside it via a recursive call).  In a multi-
+         threaded context, distinguish three cases:
+
+           (a) Constructor already completed (l_init_once == 2): done.
+           (b) Recursive call from the same thread that is currently
+               running the constructor (l_init_owner == current TID):
+               return immediately, otherwise we would wait for our own
+               constructor to finish and deadlock.
+           (c) Concurrent call from another thread that is currently
+               running the constructor: wait for completion.  Without
+               this wait, our caller's dlopen() would return before the
+               constructor finishes - a regression from the pre-BZ-15686
+               model where dl_load_lock serialised the whole _dl_open.  */
+      if (!RTLD_SINGLE_THREAD_P
+          && atomic_load_acquire (&l->l_init_once) != 2
+          && l->l_init_owner != THREAD_GETMEM (THREAD_SELF, tid))
+        {
+          while (atomic_load_acquire (&l->l_init_once) != 2)
+            DL_INIT_ONCE_WAIT (&l->l_init_once, 1, LLL_PRIVATE);
+        }
+      return;
+    }
 
   /* Avoid handling this constructor again in case we have a circular
      dependency.  */
@@ -45,7 +87,49 @@ call_init (struct link_map *l, int argc, char **argv, char **env)
   /* Check for object which constructors we do not run here.  */
   if (__builtin_expect (l->l_name[0], 'a') == '\0'
       && l->l_type == lt_executable)
-    return;
+    {
+      /* The main executable's constructors are run by the startup code,
+         not here.  Nevertheless we must mark the map as fully
+         initialized so that a later multi-threaded dlopen(NULL) /
+         __RTLD_OPENEXEC caller does not wait forever on l_init_once
+         in dl_open_worker.  */
+      atomic_store_release (&l->l_init_once, 2);
+      if (!RTLD_SINGLE_THREAD_P)
+        DL_INIT_ONCE_WAKE (&l->l_init_once, INT_MAX, LLL_PRIVATE);
+      return;
+    }
+
+  /* When single-threaded (startup, or dlopen before any threads exist),
+     run the constructor inline.  When multi-threaded, use per-DSO
+     serialisation: the first caller runs the constructor; any concurrent
+     caller blocks until it completes.  Different DSOs can initialise
+     concurrently.  */
+  if (!RTLD_SINGLE_THREAD_P)
+    {
+      /* Fast path: already initialized.  */
+      if (atomic_load_acquire (&l->l_init_once) == 2)
+        return;
+
+      /* Try to acquire the lock (CAS 0 -> 1).  */
+      if (atomic_compare_and_exchange_bool_acq (&l->l_init_once, 1, 0) != 0)
+        {
+          /* Another thread is initializing.  Wait until it finishes.  */
+          while (atomic_load_acquire (&l->l_init_once) != 2)
+            DL_INIT_ONCE_WAIT (&l->l_init_once, 1, LLL_PRIVATE);
+          return;
+        }
+
+      /* We won the CAS - record our TID so a recursive call_init from
+         inside the constructor (e.g. via a transitive dlopen) can
+         recognise itself and return without deadlocking.  */
+      l->l_init_owner = THREAD_GETMEM (THREAD_SELF, tid);
+
+      /* We are now the init owner; l_init_pending has done its job of
+         signalling "init is scheduled" to early-return waiters in
+         dl_open_worker_begin.  Clear it so they don't keep spinning
+         on it after init completes.  */
+      atomic_store_release (&l->l_init_pending, 0);
+    }
 
   /* Print a debug message if wanted.  */
   if (__glibc_unlikely (GLRO(dl_debug_mask) & DL_DEBUG_IMPCALLS))
@@ -73,6 +157,15 @@ call_init (struct link_map *l, int argc, char **argv, char **env)
       for (j = 0; j < jm; ++j)
 	((dl_init_t) addrs[j]) (argc, argv, env);
     }
+
+  /* Mark the DSO as fully initialised so that a later call_init from
+     another thread (which can happen transitively when a new DSO is
+     loaded that depends on this one) sees l_init_once == 2 and does
+     not wait.  In single-threaded mode there can be no waiters, so
+     the futex wake is skipped.  */
+  atomic_store_release (&l->l_init_once, 2);
+  if (!RTLD_SINGLE_THREAD_P)
+    DL_INIT_ONCE_WAKE (&l->l_init_once, INT_MAX, LLL_PRIVATE);
 }
 
 
diff --git a/elf/dl-open.c b/elf/dl-open.c
index 87fcee8b02..b4e714a6cf 100644
--- a/elf/dl-open.c
+++ b/elf/dl-open.c
@@ -37,6 +37,7 @@
 #include <libc-early-init.h>
 #include <gnu/lib-names.h>
 #include <dl-find_object.h>
+#include <dl-tunables.h>
 
 #include <dl-dst.h>
 #include <dl-prop.h>
@@ -597,6 +598,48 @@ dl_open_worker_begin (void *a)
 	 dlopen (NULL, RTLD_LAZY) call from a constructor of an
 	 initially loaded shared object.  */
 
+      /* BZ 15686: dl_load_lock is released during the constructor on
+	 the thread that first loaded this DSO, so this thread may have
+	 reached the already-loaded early-return path while that
+	 constructor is still running (or, in a tight race, after the
+	 loader thread released the lock but before it reached
+	 call_init - covered by l_init_pending which is set under the
+	 lock).  Without this wait, dlopen would return before the
+	 constructor finished - a regression from the pre-BZ-15686 model
+	 where dl_load_lock serialised the whole _dl_open.
+
+	 We only wait; we do not run the ctor ourselves.  Per the
+	 comment above, running _dl_init here could expose partially
+	 constructed state to objects that depend on this DSO if this
+	 dlopen call came from inside another ELF constructor.  The
+	 loader thread that scheduled the init (signalled by
+	 l_init_pending or l_init_called) will run the ctor when it
+	 reaches call_init.
+
+	 Fast path: if l_init_once == 2, ctor already finished.
+
+	 Release dl_load_lock before waiting so concurrent dlopen
+	 callers are not blocked.  */
+      if (!RTLD_SINGLE_THREAD_P
+	  && (atomic_load_acquire (&new->l_init_pending)
+	      || new->l_init_called)
+	  && atomic_load_acquire (&new->l_init_once) != 2
+	  && new->l_init_owner != THREAD_GETMEM (THREAD_SELF, tid))
+	{
+	  bool unlock_for_ctor
+	    = TUNABLE_GET (glibc, rtld, strict_init_order,
+			   int32_t, NULL) == 0;
+
+	  if (unlock_for_ctor)
+	    __rtld_lock_unlock_recursive (GL(dl_load_lock));
+
+	  while (atomic_load_acquire (&new->l_init_once) != 2)
+	    lll_futex_wait (&new->l_init_once, 1, LLL_PRIVATE);
+
+	  if (unlock_for_ctor)
+	    __rtld_lock_lock_recursive (GL(dl_load_lock));
+	}
+
       return;
     }
 
@@ -792,11 +835,46 @@ dl_open_worker (void *a)
   int mode = args->mode;
   struct link_map *new = args->map;
 
+  /* By default, release dl_load_lock so constructors can spawn
+     threads without deadlocking (e.g. if the new thread's first
+     thread_local access triggers __cxa_thread_atexit_impl, which
+     needs dl_load_lock).  See BZ 15686.
+
+     The DSO has l_direct_opencount == 1, so a concurrent dlclose
+     cannot unload it.  Per-DSO serialisation is handled in call_init
+     (dl-init.c) via l_init_once + futex, and the l_init_owner check
+     in call_init makes a concurrent dlopen caller wait for the
+     constructor to finish (rather than returning prematurely).
+
+     Setting glibc.rtld.strict_init_order=1 disables the unlock and
+     reverts to the pre-BZ-15686 model where dl_load_lock is held
+     across constructor execution.  This gives a strict total order
+     of dlopen calls across threads - useful for diagnosing
+     applications that implicitly relied on that order, at the cost
+     of reintroducing the deadlock.  */
+  bool release_lock_for_ctor
+    = TUNABLE_GET (glibc, rtld, strict_init_order, int32_t, NULL) == 0;
+
+  /* Signal "init is scheduled" while still holding dl_load_lock, so a
+     concurrent dlopen caller that takes the early-return path for an
+     already-loaded DSO knows to wait for the ctor even before
+     call_init runs.  Cleared by call_init after it wins the
+     l_init_once CAS.  See BZ 15686.  */
+  atomic_store_release (&new->l_init_pending, 1);
+
+  if (release_lock_for_ctor)
+    __rtld_lock_unlock_recursive (GL(dl_load_lock));
+
   /* Run the initializer functions of new objects.  Temporarily
      disable the exception handler, so that lazy binding failures are
      fatal.  */
   _dl_catch_exception (NULL, call_dl_init, args);
 
+  /* 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));
+
   /* Now we can make the new map available in the global scope.  */
   if (mode & RTLD_GLOBAL)
     add_to_global_update (new);
@@ -889,10 +967,43 @@ no more namespaces available for dlmopen()"));
   args.map = _dl_lookup_map (args.nsid, file);
   if (is_already_fully_open (args.map, mode))
     {
-      /* We can use the fast path.  */
-      ++args.map->l_direct_opencount;
+      struct link_map *map = args.map;
+
+      /* We can use the fast path.  Account for our reference BEFORE
+	 entering the wait below: while we wait, dl_load_lock is released,
+	 and another caller that already holds a reference may dlclose.
+	 Without our own increment first, the last such dlclose could
+	 drive l_direct_opencount to 0 and unload the DSO - and us with
+	 it.  */
+      ++map->l_direct_opencount;
+
+      /* BZ 15686: dl_load_lock is released during the constructor on
+	 the thread that first loaded this DSO, so this thread may have
+	 reached the already-loaded fast path while that constructor is
+	 still running (or, in a tight race, after the loader thread
+	 released the lock but before it reached call_init - covered by
+	 l_init_pending which is set under the lock).  Without this
+	 wait, dlopen would return before the constructor finished.
+
+	 Same logic as the early-return path in dl_open_worker_begin;
+	 duplicated here because this fast path bypasses the worker
+	 entirely.  */
+      if (!RTLD_SINGLE_THREAD_P
+	  && (atomic_load_acquire (&map->l_init_pending)
+	      || map->l_init_called)
+	  && atomic_load_acquire (&map->l_init_once) != 2
+	  && map->l_init_owner != THREAD_GETMEM (THREAD_SELF, tid))
+	{
+	  __rtld_lock_unlock_recursive (GL(dl_load_lock));
+
+	  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));
+	}
+
       __rtld_lock_unlock_recursive (GL(dl_load_lock));
-      return args.map;
+      return map;
     }
 
   struct dl_exception exception;
diff --git a/elf/dl-tunables.list b/elf/dl-tunables.list
index 111649f145..5149dbe1ab 100644
--- a/elf/dl-tunables.list
+++ b/elf/dl-tunables.list
@@ -113,6 +113,12 @@ glibc {
       maxval: 2
       default: 1
     }
+    strict_init_order {
+      type: INT_32
+      minval: 0
+      maxval: 1
+      default: 0
+    }
   }
 
   mem {
diff --git a/elf/tst-rtld-list-tunables.exp b/elf/tst-rtld-list-tunables.exp
index 9590021f3a..2a4c7a5eda 100644
--- a/elf/tst-rtld-list-tunables.exp
+++ b/elf/tst-rtld-list-tunables.exp
@@ -15,3 +15,4 @@ glibc.rtld.enable_secure: 0 (min: 0, max: 1)
 glibc.rtld.execstack: 1 (min: 0, max: 2)
 glibc.rtld.nns: 0x4 (min: 0x1, max: 0x10)
 glibc.rtld.optional_static_tls: 0x200 (min: 0x0, max: 0x[f]+)
+glibc.rtld.strict_init_order: 0 (min: 0, max: 1)
diff --git a/include/link.h b/include/link.h
index 8f851d2212..1047f2ab23 100644
--- a/include/link.h
+++ b/include/link.h
@@ -346,6 +346,28 @@ struct link_map
     size_t l_relro_size;
 
     unsigned long long int l_serial;
+
+    /* Per-DSO once-initialization control for constructor execution.
+       Used as a low-level lock (LLL): 0 = uninitialized, 1 = initializing,
+       2 = initialized.  Zero from calloc matches the unlocked state.  */
+    int l_init_once;
+
+    /* TID of the thread that won the l_init_once CAS (0 -> 1) and is
+       currently running this DSO's constructor.  Lets a recursive
+       call_init (originating from inside the constructor itself, e.g.
+       via a transitive dlopen of a circular dependency) distinguish
+       itself from a concurrent call_init on another thread and return
+       immediately instead of waiting for itself.  Zero from calloc.  */
+    pid_t l_init_owner;
+
+    /* Set to 1 under dl_load_lock by dl_open_worker_begin just before
+       releasing the lock to run the constructor.  Lets a concurrent
+       dlopen caller that takes the early-return path for an
+       already-loaded DSO know that initialisation is scheduled and
+       worth waiting for, even before call_init has set l_init_called
+       and won the l_init_once CAS.  Cleared to 0 by call_init once it
+       has won the CAS and become the init owner.  Zero from calloc.  */
+    int l_init_pending;
   };
 
 #include <dl-relocate-ld.h>
diff --git a/manual/tunables.texi b/manual/tunables.texi
index e7dcef1040..41fa08cfce 100644
--- a/manual/tunables.texi
+++ b/manual/tunables.texi
@@ -470,6 +470,35 @@ can be worked around by setting the tunable to @code{2}, where the stack is
 always executable.
 @end deftp
 
+@deftp Tunable glibc.rtld.strict_init_order
+Controls whether @theglibc{} retains @code{dl_load_lock} across the
+execution of ELF constructors run by @code{dlopen}.
+
+The default value of @samp{0} releases @code{dl_load_lock} before running
+the new objects' constructors and reacquires it afterwards.  This prevents
+the deadlock described in @uref{https://sourceware.org/bugzilla/show_bug.cgi?id=15686, BZ 15686}:
+a constructor that spawns a thread whose first @code{thread_local} access
+calls @code{__cxa_thread_atexit_impl} would otherwise block forever on
+@code{dl_load_lock} held by the @code{dlopen} caller.  Per-DSO
+serialisation of constructor execution (via @code{l_init_once} in
+@file{elf/dl-init.c}) preserves the ELF guarantee that a DSO's
+dependencies are initialised before the DSO itself, and concurrent
+@code{dlopen} callers wait for the in-progress constructor to finish.
+
+Setting this tunable to @samp{1} reverts to the pre-BZ-15686 behaviour:
+@code{dl_load_lock} is held across constructor execution, giving a
+strict total order of @code{dlopen} calls across threads at the cost of
+reintroducing the BZ 15686 deadlock.  This is intended as an escape
+hatch for applications that implicitly relied on the old total order
+(e.g., plugin registries whose registration order determined behaviour)
+and need time to fix the underlying assumption.
+
+@strong{NB:} with @samp{1}, any @code{dlopen} of a DSO whose
+constructor spawns a thread touching @code{thread_local} state (directly
+or via libraries like glycin, gdk-pixbuf-glycin, NSS, etc.) will
+deadlock.  Use only as a temporary diagnostic aid.
+@end deftp
+
 @node POSIX Thread Tunables
 @section POSIX Thread Tunables
 @cindex pthread mutex tunables
diff --git a/sysdeps/pthread/Makefile b/sysdeps/pthread/Makefile
index d0f3cd59ac..00f6a0cd35 100644
--- a/sysdeps/pthread/Makefile
+++ b/sysdeps/pthread/Makefile
@@ -349,6 +349,10 @@ tests += \
   tst-atfork3 \
   tst-atfork4 \
   tst-create1 \
+  tst-create2 \
+  tst-create3 \
+  tst-create4 \
+  tst-create5 \
   tst-fini1 \
   tst-pt-tls4 \
   # tests
@@ -365,6 +369,10 @@ modules-names += \
   tst-atfork3mod \
   tst-atfork4mod \
   tst-create1mod \
+  tst-create2mod \
+  tst-create3mod \
+  tst-create4mod-a \
+  tst-create4mod-b \
   tst-fini1mod \
   tst-stack2-mod \
   tst-tls4moda \
@@ -377,6 +385,10 @@ tst-atfork2mod.so-no-z-defs = yes
 tst-atfork3mod.so-no-z-defs = yes
 tst-atfork4mod.so-no-z-defs = yes
 tst-create1mod.so-no-z-defs = yes
+tst-create2mod.so-no-z-defs = yes
+tst-create3mod.so-no-z-defs = yes
+tst-create4mod-a.so-no-z-defs = yes
+tst-create4mod-b.so-no-z-defs = yes
 
 ifeq ($(build-shared),yes)
 # Build all the modules even when not actually running test programs.
@@ -549,3 +561,27 @@ endif
 tst-stack2-TUNABLES += glibc.rtld.execstack=2
 
 endif
+
+LDFLAGS-tst-create2 = -Wl,-export-dynamic
+$(objpfx)tst-create2: $(shared-thread-library)
+$(objpfx)tst-create2.out: $(objpfx)tst-create2mod.so
+
+LDFLAGS-tst-create3 = -Wl,-export-dynamic
+$(objpfx)tst-create3: $(shared-thread-library)
+$(objpfx)tst-create3.out: $(objpfx)tst-create3mod.so
+
+# tst-create4 documents a semantic change introduced by the BZ 15686
+# fix: with dl_load_lock released during constructors, independent
+# DSOs loaded from different threads can have their constructors
+# interleave, which was impossible under the pre-fix locking model.
+# The test expects the OLD non-interleaving behaviour and therefore
+# fails on patched glibc.  Mark it as expected-fail so the suite stays
+# green; flip the xfail off to reproduce/diagnose ordering-dependent
+# regressions in downstream applications.
+LDFLAGS-tst-create4 = -Wl,-export-dynamic
+$(objpfx)tst-create4: $(shared-thread-library)
+$(objpfx)tst-create4.out: $(objpfx)tst-create4mod-a.so $(objpfx)tst-create4mod-b.so
+test-xfail-tst-create4 = yes
+
+LDFLAGS-tst-create5 = -Wl,-export-dynamic
+$(objpfx)tst-create5: $(shared-thread-library)
diff --git a/sysdeps/pthread/tst-create2.c b/sysdeps/pthread/tst-create2.c
new file mode 100644
index 0000000000..d7df8aafc5
--- /dev/null
+++ b/sysdeps/pthread/tst-create2.c
@@ -0,0 +1,48 @@
+/* Verify that a thread spawned by a dlopen constructor can register a
+   TLS destructor via __cxa_thread_atexit_impl without deadlocking on
+   dl_load_lock held by the dlopen caller (BZ 15686).
+   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/>.  */
+
+/* Reproducer for one instance of the deadlock class described in
+   BZ 15686.
+
+   thread 1: dlopen -> ctor -> pthread_create(worker) -> pthread_join(worker)
+   thread 2 (worker): __cxa_thread_atexit_impl -> tries to lock dl_load_lock
+
+   Before the fix in elf/dl-open.c, dl_load_lock is held across
+   call_dl_init, so thread 2 blocks on a lock that thread 1 will only
+   release after pthread_join returns -- a deadlock that the
+   test-driver timeout surfaces as a failure.  After the fix,
+   dl_load_lock is released before constructors run and reacquired
+   afterwards, so thread 2 makes progress and the dlopen call returns.  */
+
+#include <stdio.h>
+#include <support/xdlfcn.h>
+
+static int
+do_test (void)
+{
+  dprintf (1, "main: dlopen tst-create2mod.so\n");
+  void *h = xdlopen ("tst-create2mod.so", RTLD_NOW);
+  dprintf (1, "main: dlopen done\n");
+  xdlclose (h);
+  dprintf (1, "main: dlclose done\n");
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/sysdeps/pthread/tst-create2mod.c b/sysdeps/pthread/tst-create2mod.c
new file mode 100644
index 0000000000..d8254f729f
--- /dev/null
+++ b/sysdeps/pthread/tst-create2mod.c
@@ -0,0 +1,62 @@
+/* Verify that a thread spawned by a dlopen constructor can register a
+   TLS destructor via __cxa_thread_atexit_impl without deadlocking on
+   dl_load_lock held by the dlopen caller (BZ 15686).
+   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 <pthread.h>
+#include <stdlib.h>
+#include <dso_handle.h>
+
+typedef struct { void *val; } A;
+
+static void
+A_dtor (void *obj)
+{
+  ((A *) obj)->val = obj;
+}
+
+/* Acquire dl_load_lock via __cxa_thread_atexit_impl.  Called from the
+   worker thread spawned by the constructor below; if the constructor
+   runs with dl_load_lock held, this blocks and pthread_join in the
+   constructor never returns.  */
+static void
+reg_dtor (void)
+{
+  static __thread A b;
+  __cxa_thread_atexit_impl (A_dtor, &b, __dso_handle);
+}
+
+static void *
+worker (void *arg)
+{
+  reg_dtor ();
+  return NULL;
+}
+
+static void __attribute__ ((constructor))
+do_init (void)
+{
+  pthread_t t;
+  if (pthread_create (&t, NULL, worker, NULL) != 0)
+    abort ();
+  /* Blocks until worker has completed its __cxa_thread_atexit_impl
+     call; under the pre-fix locking model that call deadlocks on
+     dl_load_lock held by the dlopen caller running this ctor.  */
+  if (pthread_join (t, NULL) != 0)
+    abort ();
+}
diff --git a/sysdeps/pthread/tst-create3.c b/sysdeps/pthread/tst-create3.c
new file mode 100644
index 0000000000..c3b8537255
--- /dev/null
+++ b/sysdeps/pthread/tst-create3.c
@@ -0,0 +1,123 @@
+/* Verify that concurrent dlopen of the same DSO is safe under the
+   per-DSO init serialisation added for BZ 15686.
+   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/>.  */
+
+/* Two invariants are checked for concurrent dlopen of the same DSO:
+
+   1. The DSO's constructor runs EXACTLY ONCE, even if N threads race
+      into call_init.  This catches a world where two threads both
+      think they lost the l_init_once CAS but actually proceed.
+
+   2. No dlopen caller returns before the constructor has finished.
+      A caller that beats the constructor would observe globals in
+      their BSS-zeroed state — a regression from the pre-BZ-15686
+      model where dl_load_lock serialised the whole _dl_open.
+
+   The DSO constructor sleeps ~200 ms to widen the race window, then
+   publishes a "done" magic as its final write.  Each thread checks
+   the magic immediately after dlopen returns; any thread observing
+   the wrong value has raced ahead of the constructor.  */
+
+#include <pthread.h>
+#include <stdatomic.h>
+#include <stdio.h>
+#include <support/xdlfcn.h>
+#include <support/xthread.h>
+
+/* Must match tst-create3mod.c.  */
+#define TST_CREATE3_MAGIC_DONE 0xCAFEBABEu
+
+/* More threads than two so the race is exercised on multiple
+   pair-wise combinations; small enough to keep the test cheap.  */
+#define NTHREADS 8
+
+static pthread_barrier_t g_start_barrier;
+static atomic_int g_visibility_failures;
+
+static void *
+worker (void *arg)
+{
+  (void) arg;
+
+  /* Release all workers at once so they enter _dl_open near-simultaneously.  */
+  xpthread_barrier_wait (&g_start_barrier);
+
+  void *h = xdlopen ("tst-create3mod.so", RTLD_NOW);
+
+  /* The "done" flag is the constructor's final write.  If dlopen
+     returned before the constructor finished (the BZ 15686 race the
+     l_init_owner check in call_init prevents), this load will observe
+     0 instead of TST_CREATE3_MAGIC_DONE.  */
+  _Atomic unsigned int *done
+    = xdlsym (h, "tst_create3mod_done");
+  if (atomic_load_explicit (done, memory_order_acquire)
+      != TST_CREATE3_MAGIC_DONE)
+    {
+      printf ("FAIL: thread %lu returned from dlopen before ctor finished\n",
+	      (unsigned long) pthread_self ());
+      atomic_fetch_add_explicit (&g_visibility_failures, 1,
+				 memory_order_relaxed);
+    }
+
+  xdlclose (h);
+  return NULL;
+}
+
+static int
+do_test (void)
+{
+  pthread_t threads[NTHREADS];
+
+  xpthread_barrier_init (&g_start_barrier, NULL, NTHREADS);
+
+  for (int i = 0; i < NTHREADS; ++i)
+    threads[i] = xpthread_create (0, worker, NULL);
+
+  for (int i = 0; i < NTHREADS; ++i)
+    xpthread_join (threads[i]);
+
+  /* Re-open the DSO (it was unloaded after the last dlclose above) and
+     check the constructor ran exactly once across all concurrent
+     callers.  */
+  void *h = xdlopen ("tst-create3mod.so", RTLD_NOW);
+  _Atomic int *count = xdlsym (h, "tst_create3mod_ctor_count");
+  int n = atomic_load_explicit (count, memory_order_acquire);
+  xdlclose (h);
+
+  int failures = atomic_load_explicit (&g_visibility_failures,
+				       memory_order_relaxed);
+
+  if (n != 1)
+    {
+      printf ("FAIL: constructor ran %d times, expected exactly 1\n", n);
+      return 1;
+    }
+
+  if (failures > 0)
+    {
+      printf ("FAIL: %d thread(s) observed uninitialized DSO after dlopen\n",
+	      failures);
+      return 1;
+    }
+
+  printf ("PASS: ctor ran once; all %d callers observed initialized state\n",
+	  NTHREADS);
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/sysdeps/pthread/tst-create3mod.c b/sysdeps/pthread/tst-create3mod.c
new file mode 100644
index 0000000000..eb432a89ec
--- /dev/null
+++ b/sysdeps/pthread/tst-create3mod.c
@@ -0,0 +1,73 @@
+/* DSO for tst-create3: concurrent dlopen constructor-once test (BZ 15686).
+   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 <stdatomic.h>
+#include <time.h>
+
+/* Magic value published by the constructor as its final action.  Any
+   dlopen caller that observes tst_create3mod_done != TST_CREATE3_MAGIC_DONE
+   immediately after dlopen returned has beaten the constructor — the
+   BZ 15686 race that the l_init_owner check in call_init prevents.
+   Value kept in sync with tst-create3.c.  */
+#define TST_CREATE3_MAGIC_DONE 0xCAFEBABEu
+
+/* How long (nanoseconds) the constructor sleeps to simulate slow
+   initialisation.  Large enough that, under the pre-fix locking model,
+   concurrent dlopen callers are very likely to reach call_init while
+   the constructor is still running.  */
+#define TST_CREATE3_CTOR_SLEEP_NS 200000000	/* 200 ms */
+
+/* Counter incremented by the constructor.  Must be exactly 1 after
+   concurrent dlopen — catches the case where two threads both win the
+   CAS and run the constructor in parallel.  */
+_Atomic int tst_create3mod_ctor_count = 0;
+
+/* Visibility marker.  Set as the final write of the constructor with
+   release ordering.  Concurrent dlopen callers must observe
+   TST_CREATE3_MAGIC_DONE here after their dlopen returns.  */
+_Atomic unsigned int tst_create3mod_done = 0;
+
+static void
+sleep_ns (long ns)
+{
+  struct timespec ts =
+    {
+      .tv_sec = ns / 1000000000L,
+      .tv_nsec = ns % 1000000000L
+    };
+  nanosleep (&ts, NULL);
+}
+
+static void __attribute__ ((constructor))
+do_init (void)
+{
+  /* Record that we ran.  Two threads winning the l_init_once CAS
+     would increment this more than once.  */
+  atomic_fetch_add_explicit (&tst_create3mod_ctor_count, 1,
+			     memory_order_relaxed);
+
+  /* Slow the constructor down to widen the window in which a buggy
+     call_init would let a concurrent caller return from dlopen.  */
+  sleep_ns (TST_CREATE3_CTOR_SLEEP_NS);
+
+  /* Publish "constructor finished" as the last write with release
+     ordering, so that callers observing TST_CREATE3_MAGIC_DONE also
+     observe every earlier write the constructor made.  */
+  atomic_store_explicit (&tst_create3mod_done, TST_CREATE3_MAGIC_DONE,
+			 memory_order_release);
+}
diff --git a/sysdeps/pthread/tst-create4.c b/sysdeps/pthread/tst-create4.c
new file mode 100644
index 0000000000..beb482ccc6
--- /dev/null
+++ b/sysdeps/pthread/tst-create4.c
@@ -0,0 +1,152 @@
+/* Demonstration: the patch for BZ 15686 removes a property that some
+   applications may have relied on implicitly.
+
+   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/>.  */
+
+/* WHAT THIS TEST CHECKS
+
+   Two worker threads concurrently dlopen two *independent* DSOs (no
+   DT_NEEDED between them).  Each DSO's constructor appends a character
+   to a shared buffer in main several times, with sched_yield ()
+   between appends, to maximise the chance of any concurrency becoming
+   visible.
+
+   The test then asserts that the buffer contains either
+
+       "AAAAABBBBB"      (worker A's dlopen completed first)
+       "BBBBBAAAAA"      (worker B's dlopen completed first)
+
+   i.e. the two constructors did NOT interleave.
+
+   WHAT IT DEMONSTRATES
+
+   The "no interleave" property was a side effect of the pre-BZ-15686
+   locking model: dl_load_lock was held across the entire _dl_open
+   call, including constructor execution, so two dlopens from different
+   threads were fully serialized and their constructors could not
+   overlap.  Nothing in POSIX or the ELF spec ever guaranteed this —
+   only the implementation did.
+
+   The BZ 15686 fix releases dl_load_lock for the duration of
+   constructor execution, so that a constructor can spawn threads that
+   reach __cxa_thread_atexit_impl without deadlocking.  As a
+   consequence, constructors of independent DSOs loaded from different
+   threads CAN now overlap, and this test will (correctly) fail on the
+   patched glibc.
+
+   WHY IT'S NOT IN THE DEFAULT TEST SUITE
+
+   It checks for behaviour the patch deliberately removes.  Running it
+   on a patched glibc produces a flaky failure that does not indicate a
+   real bug.  Build it manually if you want to reproduce the semantic
+   change, e.g. when chasing a regression in an application that
+   implicitly depended on the old total-order property.
+
+     gcc -rdynamic -o tst-create4 tst-create4.c \
+         -I../... -pthread
+     gcc -shared -fPIC -o tst-create4mod-a.so tst-create4mod-a.c
+     gcc -shared -fPIC -o tst-create4mod-b.so tst-create4mod-b.c
+
+   WHAT REAL APPLICATIONS COULD BREAK
+
+   Any app that loads plugins from multiple threads and where plugin
+   constructors mutate shared state with implicit ordering:
+
+     - plugin registries that pick the first registered handler;
+     - signal-handler chains installed in constructors;
+     - global logger / telemetry setup;
+     - one ctor publishing a service that another ctor's plugin
+       queries at registration time.
+
+   If two such plugins are dlopen'd from different threads, the
+   ordering their authors observed in testing was the dl_load_lock
+   total order, not anything they explicitly synchronised on.  After
+   the patch they race, and the "first registered" plugin can flip. */
+
+#include <pthread.h>
+#include <stdatomic.h>
+#include <stdio.h>
+#include <string.h>
+#include <support/xdlfcn.h>
+#include <support/xthread.h>
+
+#define NSTEPS 5
+
+/* Shared buffer filled in by the two DSO constructors.  Exported so
+   the modules can find it via the main executable's dynamic symbol
+   table (-rdynamic).  */
+_Atomic int tst_create4_seq_idx = 0;
+char tst_create4_seq_buf[NSTEPS * 2 + 1];
+
+static pthread_barrier_t g_start_barrier;
+
+static void *
+worker_a (void *unused)
+{
+  (void) unused;
+  xpthread_barrier_wait (&g_start_barrier);
+  void *h = xdlopen ("tst-create4mod-a.so", RTLD_NOW);
+  xdlclose (h);
+  return NULL;
+}
+
+static void *
+worker_b (void *unused)
+{
+  (void) unused;
+  xpthread_barrier_wait (&g_start_barrier);
+  void *h = xdlopen ("tst-create4mod-b.so", RTLD_NOW);
+  xdlclose (h);
+  return NULL;
+}
+
+static int
+do_test (void)
+{
+  xpthread_barrier_init (&g_start_barrier, NULL, 2);
+
+  pthread_t ta = xpthread_create (0, worker_a, NULL);
+  pthread_t tb = xpthread_create (0, worker_b, NULL);
+  xpthread_join (ta);
+  xpthread_join (tb);
+
+  int len = atomic_load_explicit (&tst_create4_seq_idx,
+				  memory_order_acquire);
+  tst_create4_seq_buf[len] = '\0';
+
+  printf ("observed sequence: %s\n", tst_create4_seq_buf);
+
+  /* Pre-BZ-15686 guarantee: constructors did not interleave.  Either
+     all of A's writes happened before all of B's, or vice versa.  */
+  int ok = (strcmp (tst_create4_seq_buf, "AAAAABBBBB") == 0
+	    || strcmp (tst_create4_seq_buf, "BBBBBAAAAA") == 0);
+
+  if (!ok)
+    {
+      printf ("FAIL: constructors interleaved; this is expected on a "
+	      "BZ 15686-patched glibc where dl_load_lock is released "
+	      "during constructor execution\n");
+      return 1;
+    }
+
+  printf ("PASS: constructors did not interleave (old dl_load_lock "
+	  "behaviour)\n");
+  return 0;
+}
+
+#include <support/test-driver.c>
diff --git a/sysdeps/pthread/tst-create4mod-a.c b/sysdeps/pthread/tst-create4mod-a.c
new file mode 100644
index 0000000000..63d6b30b18
--- /dev/null
+++ b/sysdeps/pthread/tst-create4mod-a.c
@@ -0,0 +1,39 @@
+/* DSO A for tst-create4: writes 'A' into the shared buffer NSTEPS
+   times, yielding between writes to maximise visible interleaving.
+   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 <stdatomic.h>
+#include <sched.h>
+
+#define NSTEPS 5
+
+/* Defined in the main executable; resolved via the global scope.  */
+extern _Atomic int tst_create4_seq_idx;
+extern char tst_create4_seq_buf[];
+
+static void __attribute__ ((constructor))
+init_a (void)
+{
+  for (int i = 0; i < NSTEPS; ++i)
+    {
+      int idx = atomic_fetch_add_explicit (&tst_create4_seq_idx, 1,
+					   memory_order_relaxed);
+      tst_create4_seq_buf[idx] = 'A';
+      sched_yield ();
+    }
+}
diff --git a/sysdeps/pthread/tst-create4mod-b.c b/sysdeps/pthread/tst-create4mod-b.c
new file mode 100644
index 0000000000..130a39c8ad
--- /dev/null
+++ b/sysdeps/pthread/tst-create4mod-b.c
@@ -0,0 +1,39 @@
+/* DSO B for tst-create4: writes 'B' into the shared buffer NSTEPS
+   times, yielding between writes to maximise visible interleaving.
+   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 <stdatomic.h>
+#include <sched.h>
+
+#define NSTEPS 5
+
+/* Defined in the main executable; resolved via the global scope.  */
+extern _Atomic int tst_create4_seq_idx;
+extern char tst_create4_seq_buf[];
+
+static void __attribute__ ((constructor))
+init_b (void)
+{
+  for (int i = 0; i < NSTEPS; ++i)
+    {
+      int idx = atomic_fetch_add_explicit (&tst_create4_seq_idx, 1,
+					   memory_order_relaxed);
+      tst_create4_seq_buf[idx] = 'B';
+      sched_yield ();
+    }
+}
diff --git a/sysdeps/pthread/tst-create5.c b/sysdeps/pthread/tst-create5.c
new file mode 100644
index 0000000000..34edf17dac
--- /dev/null
+++ b/sysdeps/pthread/tst-create5.c
@@ -0,0 +1,63 @@
+/* Verify that dlopen(NULL) from a worker thread does not deadlock
+   after the main executable has been initialized.
+   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/>.  */
+
+/* Reproducer for a regression in the BZ 15686 fix.
+
+   The main executable's constructors are not run by call_init in
+   elf/dl-init.c; instead they are handled by the startup code.  With
+   the per-DSO init serialization added for BZ 15686, call_init must
+   nevertheless mark the main executable as fully initialized by
+   setting l_init_once = 2.  Otherwise a later multi-threaded
+   dlopen(NULL) / __RTLD_OPENEXEC takes the already-loaded early-return
+   path in dl_open_worker_begin, sees l_init_called == 1 but
+   l_init_once != 2, and waits forever for a constructor that will
+   never complete.
+
+   This test spawns a worker thread that calls dlopen(NULL, RTLD_NOW).
+   If the bug is present, the call deadlocks and the test-driver
+   timeout surfaces the failure.  After the fix, dlopen(NULL) returns
+   immediately.  */
+
+#include <stdio.h>
+#include <support/xdlfcn.h>
+#include <support/xthread.h>
+
+static void *
+worker (void *arg)
+{
+  (void) arg;
+
+  dprintf (1, "worker: dlopen(NULL)\n");
+  void *h = xdlopen (NULL, RTLD_NOW);
+  dprintf (1, "worker: dlopen(NULL) done\n");
+  xdlclose (h);
+  return NULL;
+}
+
+static int
+do_test (void)
+{
+  pthread_t t = xpthread_create (0, worker, NULL);
+  xpthread_join (t);
+
+  dprintf (1, "main: worker finished\n");
+  return 0;
+}
+
+#include <support/test-driver.c>
-- 
2.51.0



More information about the Libc-alpha mailing list