Skip to content

thread_local destructors run where no other runtime supplies them (0.15.0) - #28

Merged
Sunrisepeak merged 2 commits into
mainfrom
thread-local-destructors-run-where-no-other-runtime-supplies-them
Sep 20, 2026
Merged

Sunrisepeak merged 2 commits into
mainfrom
thread-local-destructors-run-where-no-other-runtime-supplies-them

Conversation

@Sunrisepeak

Copy link
Copy Markdown
Member

A SEVENTH PATCH SITE, AND THE FIRST WHOSE TWO LAYERS FAIL DIFFERENTLY.

The first six were guards asking which operating system where the question
was something else, and each fell into the wrong branch. This one needed the
guard opened AND the implementation behind it repaired, and repairing only
the guard exchanges a loud build failure for a silent run-time one.

LAYER ONE --- THE EXPORT

Upstream libc++abi exports __cxa_thread_atexit on Linux and Fuchsia only,
because elsewhere another runtime already does: an ordinary MinGW target gets
it from libmingw32.a, which defines exactly one such symbol. openkal
replaces the C library and its runtime together, so both sides assumed the
other would supply it. Measured on the index's compatibility suite: doctest
and spdlog stop at ld.lld: error: undefined symbol: __cxa_thread_atexit
on x86_64-windows-gnu.

This file is a source of this package and is never installed, so it takes the
package-private OPENKAL_TARGET_WINDOWS rather than the engine's
__MCPP_TARGET_WINDOWS__ --- the same distinction that chose the define in
int_lib.h.

LAYER TWO --- __thread DOES NOT SURVIVE emutls TEARDOWN

With the guard open the link succeeded AND NOTHING WAS DESTROYED. The
fallback keeps its list of pending destructors in __thread DtorList* dtors
and gives the TLS key a dummy value purely to arm the destructor. That
assumes native thread-local storage; this runtime is built with
-femulated-tls for PE. Measured under wine, same thread:

registered dtor, dtors=0x7ffffe994680, &dtors=0x7ffffe9946a8
run_dtors called, dtors=0,             &dtors=0x7ffffe9946c8

&dtors DIFFERS. emutls keeps its per-thread blocks behind a pthread key of
its own, and that key's destructor had already released this thread's block;
a read afterwards allocates a fresh, zeroed one at a new address. run_dtors
walked an empty list.

WHY AN EARLIER PROBE EXONERATED emutls. musl runs key destructors in creation
order. That probe created its own key BEFORE first touching a thread-local,
so emutls was still alive in its destructor and the read returned the
expected value. The real ordering is the reverse, and a probe cannot report
an ordering it was constructed to avoid.

THE FIX NEEDS NO NEW MECHANISM. A key destructor is already handed the key's
value; a list kept there cannot be reached by any other key's teardown.
dtors_alive goes with it, since a non-null value is the list.

THE CRITERION IS TWO CONDITIONS, NEITHER SUFFICIENT

ok: a thread_local is constructed in a spawned thread
ok: and its destructor runs when that thread ends

examples/cxx, zero failures on x86_64-linux-gnu and on
x86_64-windows-gnu under wine. A criterion asserting only that the program
linked, or only that construction happened, passes through both layers.

PATCHES.md records the site and, while that file was open, drops the emoji
markers it carried.

…15.0)

A SEVENTH PATCH SITE, AND THE FIRST WHOSE TWO LAYERS FAIL DIFFERENTLY.

The first six were guards asking which operating system where the question
was something else, and each fell into the wrong branch. This one needed the
guard opened AND the implementation behind it repaired, and repairing only
the guard exchanges a loud build failure for a silent run-time one.

LAYER ONE --- THE EXPORT

Upstream libc++abi exports `__cxa_thread_atexit` on Linux and Fuchsia only,
because elsewhere another runtime already does: an ordinary MinGW target gets
it from `libmingw32.a`, which defines exactly one such symbol. openkal
replaces the C library and its runtime together, so both sides assumed the
other would supply it. Measured on the index's compatibility suite: `doctest`
and `spdlog` stop at `ld.lld: error: undefined symbol: __cxa_thread_atexit`
on `x86_64-windows-gnu`.

This file is a source of this package and is never installed, so it takes the
package-private `OPENKAL_TARGET_WINDOWS` rather than the engine's
`__MCPP_TARGET_WINDOWS__` --- the same distinction that chose the define in
`int_lib.h`.

LAYER TWO --- `__thread` DOES NOT SURVIVE emutls TEARDOWN

With the guard open the link succeeded AND NOTHING WAS DESTROYED. The
fallback keeps its list of pending destructors in `__thread DtorList* dtors`
and gives the TLS key a dummy value purely to arm the destructor. That
assumes native thread-local storage; this runtime is built with
`-femulated-tls` for PE. Measured under wine, same thread:

    registered dtor, dtors=0x7ffffe994680, &dtors=0x7ffffe9946a8
    run_dtors called, dtors=0,             &dtors=0x7ffffe9946c8

`&dtors` DIFFERS. emutls keeps its per-thread blocks behind a pthread key of
its own, and that key's destructor had already released this thread's block;
a read afterwards allocates a fresh, zeroed one at a new address. `run_dtors`
walked an empty list.

WHY AN EARLIER PROBE EXONERATED emutls. musl runs key destructors in creation
order. That probe created its own key BEFORE first touching a thread-local,
so emutls was still alive in its destructor and the read returned the
expected value. The real ordering is the reverse, and a probe cannot report
an ordering it was constructed to avoid.

THE FIX NEEDS NO NEW MECHANISM. A key destructor is already handed the key's
value; a list kept there cannot be reached by any other key's teardown.
`dtors_alive` goes with it, since a non-null value is the list.

THE CRITERION IS TWO CONDITIONS, NEITHER SUFFICIENT

    ok: a thread_local is constructed in a spawned thread
    ok: and its destructor runs when that thread ends

`examples/cxx`, zero failures on `x86_64-linux-gnu` and on
`x86_64-windows-gnu` under wine. A criterion asserting only that the program
linked, or only that construction happened, passes through both layers.

PATCHES.md records the site and, while that file was open, drops the emoji
markers it carried.
…is an ELF idiom

THE NEW CRITERION FOUND A THIRD LAYER, ON A TARGET THAT HAD NEVER CONSTRUCTED
A thread_local WITH A DESTRUCTOR. CI on the previous commit:

    ld64.lld: error: undefined symbol: _tlv_atexit
    >>> referenced by main.cpp:316

clang lowers a `thread_local` with a non-trivial destructor to `_tlv_atexit`
on Mach-O and to `__cxa_thread_atexit` everywhere else. The two differ only in
a dso handle the fallback already ignores, so this is a forwarder, not a
second implementation. What would have supplied `_tlv_atexit` is libSystem,
which is what this stack replaces.

OPENING THE GUARD TO `__APPLE__` THEN FAILED ON A DIFFERENT SYMBOL:

    ld64.lld: error: undefined symbol: __cxa_thread_atexit_impl

Upstream declares that symbol weak and tests it against null. THAT IS AN ELF
IDIOM --- an unresolved weak symbol there IS zero. ld64 does not do it in a
static link, so the probe becomes the error. The test is also pointless on
this target, since what would define the symbol is libSystem. Apple goes
straight to the fallback, whose body is now a named function so the two
callers are one implementation rather than two copies.

MEASURED: all three targets build, cross-compiled from a Linux host, and the
two runnable ones report `failures: 0` --- Windows through wine. macOS
execution is covered by this workflow's `host-dimension` and `run-on-macos`.

The criterion's value is not that it is green today. It is that a class of
check which had no object on any target now has one: before it, nothing in
these examples declared such a variable, so neither Apple gap was present to
be found.
@Sunrisepeak
Sunrisepeak merged commit 4a0959c into main Sep 20, 2026
10 checks passed
@Sunrisepeak
Sunrisepeak deleted the thread-local-destructors-run-where-no-other-runtime-supplies-them branch September 20, 2026 21:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant