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

Artem Proskurnev <[email protected]>
Newsgroups gmane.comp.lib.glibc.alpha
Message-ID <[email protected]>
* Florian Weimer

> The bug is calling gdk_pixbuf_new_from_file from an ELF constructor?
> Alternatively, the backends probably shouldn't spawn threads unless the
> process is already multi-threaded.
>
> Adhemerval's fix seems simple enough that we can merge it whether it
> helps this particular scenario or not.
>
On the last point, agreed.  My earlier reply to Adhemerval in this
thread says explicitly to take his lock-free __cxa_thread_atexit_impl
patch independently of v3.  It is a strict improvement and orthogonal
to v3; both should land.

On the first two points, I think the framing is too narrow.

== Constructors that do real work are legitimate

ELF constructors are not limited to type registration and static
initialisers.  Across the GLib/GTK/GNOME ecosystem and plugin systems
generally, constructors that perform real work -- loading config,
registering with a session bus, initialising an image loader, setting
up a sandboxed subprocess -- are a long-standing supported pattern.
GObject type registration itself is driven by constructors that can
reach arbitrary code paths.

The glycin case specifically: gdk-pixbuf routes image loading through
a sandboxed loader when one is installed.  Calling
gdk_pixbuf_new_from_file() from a plugin ctor is not exotic; it is
what every image-handling plugin does at load time, and it has worked
for years.  The regression appeared with Rust 1.94, where
std::thread::spawn gained ChildSpawnHooks backed by a new thread_local,
and that thread_local is what eventually hit __cxa_thread_atexit_impl
on the spawned worker.  Pinning the bug on "the ctor does I/O"
rewrites a stable API contract that the deployed ecosystem already
relies on.

And even if we decided ctors shouldn't do I/O, the deployed surface
already does.  Telling users to rewrite their plugins is not a
release-time answer, and the same class of bug stays one refactor
away from recurring in whatever replaces the ctor-I/O pattern.

== "Backends shouldn't spawn threads" doesn't fit the glycin case

Two problems with the backend-side framing.

(1) There is no portable, race-free way for a library to check
whether the process is already multi-threaded at the moment it
considers spawning.  pthread_is_multithreaded_np does not exist;
inspecting /proc/self/status is racy and not portable.  So this
would require glibc to expose a new API, and then require every
thread-spawning library in the ecosystem to adopt it.  That is a
larger surface change than v3, not a smaller one.

(2) The glycin case isn't actually "backend spawned a thread, thread
raced with ctor".  It is "backend spawned a worker for sandbox setup;
the worker reached getgrouplist -> NSS -> __libc_dlopen_mode ->
_dl_open -> dl_load_lock".  The same blocking shape fires for any
code path that touches NSS during the ctor window, with or without a
spawned thread -- a ctor that calls getpwnam, getgrnam, getaddrinfo,
gethostbyname, or any of the get*ent family hits it the same way.
Spawning is one shape; the underlying issue is that dl_load_lock is
held while running arbitrary user code that may legitimately need to
re-enter the loader.

If the answer is "applications should pre-cache NSS results before
calling dlopen", then every plugin host in the ecosystem needs to
pre-call every NSS-touching function any plugin might reach during
construction.  Nothing does this today, and neither POSIX nor the
glibc manual currently asks it to.

== Why this points at v3

The two framings above share a property: they accept that dl_load_lock
must be held during ctor execution, and they shift the burden onto
applications and libraries to avoid whatever code paths happen to need
it.  That is the per-case patching strategy that produced the BZ 15686
history -- each new manifestation is a new bug, a new narrow fix, and
a new round of downstream regressions.

v3 removes the underlying invariant.  Whatever a ctor (or a thread it
spawns) does during the ctor window becomes a legitimate operation,
because the lock those operations compete for is no longer held by
the thread running the ctor.  That covers __cxa_thread_atexit_impl,
the NSS path, dlsym, _dl_addr, _dl_find_dso_for_object, recursive
dlopen, and the rest of the surface.  Adhemerval's patch cleans up
one of those paths more elegantly than v3 does; it does not remove
the class.

The A/B result earlier in this thread shows this concretely:
azanella/bz15686 deadlocks on the glycin reproducer because the NSS
path is what fires there, not __cxa_thread_atexit_impl.  v3 does not.

I would rather we land both patches than choose between them, but if
the choice is forced, v3 is the one that fixes the user-visible bug.

Thanks,
Artem
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.