thread_local destructors run where no other runtime supplies them (0.15.0) - #28
Merged
Sunrisepeak merged 2 commits intoSep 20, 2026
Conversation
…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
deleted the
thread-local-destructors-run-where-no-other-runtime-supplies-them
branch
September 20, 2026 21:29
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_atexiton 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. openkalreplaces the C library and its runtime together, so both sides assumed the
other would supply it. Measured on the index's compatibility suite:
doctestand
spdlogstop atld.lld: error: undefined symbol: __cxa_thread_atexiton
x86_64-windows-gnu.This file is a source of this package and is never installed, so it takes the
package-private
OPENKAL_TARGET_WINDOWSrather than the engine's__MCPP_TARGET_WINDOWS__--- the same distinction that chose the define inint_lib.h.LAYER TWO ---
__threadDOES NOT SURVIVE emutls TEARDOWNWith the guard open the link succeeded AND NOTHING WAS DESTROYED. The
fallback keeps its list of pending destructors in
__thread DtorList* dtorsand gives the TLS key a dummy value purely to arm the destructor. That
assumes native thread-local storage; this runtime is built with
-femulated-tlsfor PE. Measured under wine, same thread:&dtorsDIFFERS. emutls keeps its per-thread blocks behind a pthread key ofits 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_dtorswalked 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_alivegoes with it, since a non-null value is the list.THE CRITERION IS TWO CONDITIONS, NEITHER SUFFICIENT
examples/cxx, zero failures onx86_64-linux-gnuand onx86_64-windows-gnuunder wine. A criterion asserting only that the programlinked, 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.