[clang] [llvm] [ORC] Skip lazy materialization in JIT deinit symbol lookup. (PR #196874)
Vassil Vassilev via cfe-commits
cfe-commits at lists.llvm.org
Sun May 10 22:03:32 PDT 2026
https://github.com/vgvassilev created https://github.com/llvm/llvm-project/pull/196874
`LLJIT::deinitialize` (via `GenericLLVMIRPlatformSupport`) collects each JITDylib's deinit symbols (`__lljit_run_atexits` plus per-module dtors) through `Platform::lookupInitSymbols`, which triggers fresh JIT materialization for symbols that were emitted but never reached. When deinitialize runs from a static destructor -- as happens for embedders that hold the JIT in a function-local static (e.g. CppInterOp) -- that compile pipeline can reach into LLVM function-local statics already destroyed by their own static destructors (`OptBisect.cpp`'s `OptBisector`/`OptDisabler`, `TargetLibraryInfoImpl::Strings`, ...). The result is a SEGFAULT in SelectionDAG codegen, or a use-of- uninitialized-value report under MSan, with a stack rooted at `~Interpreter` -> `OrcIncrementalExecutor::cleanUp` -> `lookupInitSymbols` -> `IRTransformLayer::emit` -> `LowerEmuTLS::runOnModule` -> `getGlobalPassGate`.
Add `Platform::lookupResolvedInitSymbols`: walks each JITDylib's symbol table under the session lock, keeps only entries already at `>= SymbolState::Resolved`, defers to the existing `lookupInitSymbols` on the filtered set (now no-op materialization-wise, since every remaining symbol is already resolved). Switch
`GenericLLVMIRPlatformSupport::getDeinitializers` to use it; replace its "every JD has at least __lljit_run_atexits" assertion with a `continue`, since the new filter can legitimately drop a JD whose run-atexits thunk was never materialized.
A symbol that never materialized never ran its constructor either, so skipping its destructor is correct behavior, not a leak. The init path (`getInitializers`) keeps the existing materializing lookup -- program startup wants ctors to run. Two regression tests are included: a unit test in `CoreAPIsTest` pinning the no-materialization contract of the new helper (with `lookupInitSymbols` as baseline), and an `EXPECT_EXIT`-based clang-repl test in `InterpreterTest` that holds two Interpreters in function-local statics and `std::exit`s -- it SEGFAULTs without this patch and exits cleanly with it.
>From 0f961cf2a6f82680e08c25949152a5b4f7e526b5 Mon Sep 17 00:00:00 2001
From: Vassil Vassilev <v.g.vassilev at gmail.com>
Date: Sun, 10 May 2026 19:15:59 +0000
Subject: [PATCH] [ORC] Skip lazy materialization in JIT deinit symbol lookup.
`LLJIT::deinitialize` (via `GenericLLVMIRPlatformSupport`) collects each
JITDylib's deinit symbols (`__lljit_run_atexits` plus per-module dtors)
through `Platform::lookupInitSymbols`, which triggers fresh JIT
materialization for symbols that were emitted but never reached. When
deinitialize runs from a static destructor -- as happens for embedders
that hold the JIT in a function-local static (e.g. CppInterOp) -- that
compile pipeline can reach into LLVM function-local statics already
destroyed by their own static destructors (`OptBisect.cpp`'s
`OptBisector`/`OptDisabler`, `TargetLibraryInfoImpl::Strings`, ...).
The result is a SEGFAULT in SelectionDAG codegen, or a use-of-
uninitialized-value report under MSan, with a stack rooted at
`~Interpreter` -> `OrcIncrementalExecutor::cleanUp` -> `lookupInitSymbols`
-> `IRTransformLayer::emit` -> `LowerEmuTLS::runOnModule` ->
`getGlobalPassGate`.
Add `Platform::lookupResolvedInitSymbols`: walks each JITDylib's symbol
table under the session lock, keeps only entries already at
`>= SymbolState::Resolved`, defers to the existing `lookupInitSymbols`
on the filtered set (now no-op materialization-wise, since every
remaining symbol is already resolved). Switch
`GenericLLVMIRPlatformSupport::getDeinitializers` to use it; replace
its "every JD has at least __lljit_run_atexits" assertion with a
`continue`, since the new filter can legitimately drop a JD whose
run-atexits thunk was never materialized.
A symbol that never materialized never ran its constructor either, so
skipping its destructor is correct behavior, not a leak. The init path
(`getInitializers`) keeps the existing materializing lookup -- program
startup wants ctors to run. Two regression tests are included: a unit
test in `CoreAPIsTest` pinning the no-materialization contract of the
new helper (with `lookupInitSymbols` as baseline), and an
`EXPECT_EXIT`-based clang-repl test in `InterpreterTest` that holds
two Interpreters in function-local statics and `std::exit`s -- it
SEGFAULTs without this patch and exits cleanly with it.
---
.../unittests/Interpreter/InterpreterTest.cpp | 26 ++++++++++
llvm/include/llvm/ExecutionEngine/Orc/Core.h | 10 ++++
llvm/lib/ExecutionEngine/Orc/Core.cpp | 23 +++++++++
llvm/lib/ExecutionEngine/Orc/LLJIT.cpp | 8 +--
.../ExecutionEngine/Orc/CoreAPIsTest.cpp | 51 +++++++++++++++++++
5 files changed, 115 insertions(+), 3 deletions(-)
diff --git a/clang/unittests/Interpreter/InterpreterTest.cpp b/clang/unittests/Interpreter/InterpreterTest.cpp
index 9ff9092524d21..52261c573ffae 100644
--- a/clang/unittests/Interpreter/InterpreterTest.cpp
+++ b/clang/unittests/Interpreter/InterpreterTest.cpp
@@ -443,4 +443,30 @@ TEST_F(InterpreterTest, TranslationUnit_CanonicalDecl) {
sema.getASTContext().getTranslationUnitDecl()->getCanonicalDecl());
}
+TEST_F(InterpreterTest, ShutdownDoesNotMaterializeAgainstDestroyedGlobals) {
+#ifdef CLANG_INTERPRETER_PLATFORM_CANNOT_CREATE_LLJIT
+ GTEST_SKIP() << "Platform cannot create LLJIT";
+#endif
+ EXPECT_EXIT(
+ {
+ // Function-local statics so the dtors run during process exit,
+ // not when the lambda returns.
+ static auto I1 = createInterpreter();
+ static auto I2 = createInterpreter();
+
+ // Static initializers in JIT'd code emit init + deinit symbols
+ // that the JIT lazily materializes on demand. Two interpreters
+ // is the minimal repro for the original embedder pattern.
+ cantFail(I1->ParseAndExecute(
+ "struct S1 { S1() {} ~S1() {} }; static S1 s1;"));
+ cantFail(I2->ParseAndExecute(
+ "struct S2 { S2() {} ~S2() {} }; static S2 s2;"));
+
+ // Trigger the C++ static-dtor phase explicitly (death-test
+ // _exit otherwise skips it).
+ std::exit(0);
+ },
+ ::testing::ExitedWithCode(0), "");
+}
+
} // end anonymous namespace
diff --git a/llvm/include/llvm/ExecutionEngine/Orc/Core.h b/llvm/include/llvm/ExecutionEngine/Orc/Core.h
index 2c0b17de5fa22..28adf566633c6 100644
--- a/llvm/include/llvm/ExecutionEngine/Orc/Core.h
+++ b/llvm/include/llvm/ExecutionEngine/Orc/Core.h
@@ -1316,6 +1316,16 @@ class LLVM_ABI Platform {
lookupInitSymbolsAsync(unique_function<void(Error)> OnComplete,
ExecutionSession &ES,
const DenseMap<JITDylib *, SymbolLookupSet> &InitSyms);
+
+ /// Like lookupInitSymbols but does not trigger materialization:
+ /// symbols whose state is < SymbolState::Resolved are dropped from
+ /// the result. Intended for deinit paths -- a symbol that never
+ /// materialized never ran its initializer, so skipping its
+ /// deinitializer is correct.
+ LLVM_ABI static Expected<DenseMap<JITDylib *, SymbolMap>>
+ lookupResolvedInitSymbols(
+ ExecutionSession &ES,
+ const DenseMap<JITDylib *, SymbolLookupSet> &InitSyms);
};
/// A materialization task.
diff --git a/llvm/lib/ExecutionEngine/Orc/Core.cpp b/llvm/lib/ExecutionEngine/Orc/Core.cpp
index ac780dc82ae5a..82aba560e3ca2 100644
--- a/llvm/lib/ExecutionEngine/Orc/Core.cpp
+++ b/llvm/lib/ExecutionEngine/Orc/Core.cpp
@@ -1512,6 +1512,29 @@ Expected<DenseMap<JITDylib *, SymbolMap>> Platform::lookupInitSymbols(
return std::move(CompoundResult);
}
+Expected<DenseMap<JITDylib *, SymbolMap>> Platform::lookupResolvedInitSymbols(
+ ExecutionSession &ES,
+ const DenseMap<JITDylib *, SymbolLookupSet> &InitSyms) {
+ // Filter to already-resolved symbols, then defer to lookupInitSymbols
+ // -- the filtered lookup triggers no fresh materialization.
+ DenseMap<JITDylib *, SymbolLookupSet> Filtered;
+ ES.runSessionLocked([&]() {
+ for (const auto &KV : InitSyms) {
+ JITDylib *JD = KV.first;
+ SymbolLookupSet Keep;
+ for (const auto &[Name, Flags] : KV.second) {
+ auto SymI = JD->Symbols.find(Name);
+ if (SymI != JD->Symbols.end() &&
+ SymI->second.getState() >= SymbolState::Resolved)
+ Keep.add(Name, Flags);
+ }
+ if (!Keep.empty())
+ Filtered.try_emplace(JD, std::move(Keep));
+ }
+ });
+ return lookupInitSymbols(ES, Filtered);
+}
+
void Platform::lookupInitSymbolsAsync(
unique_function<void(Error)> OnComplete, ExecutionSession &ES,
const DenseMap<JITDylib *, SymbolLookupSet> &InitSyms) {
diff --git a/llvm/lib/ExecutionEngine/Orc/LLJIT.cpp b/llvm/lib/ExecutionEngine/Orc/LLJIT.cpp
index 18db7d24a8262..edc07062d4525 100644
--- a/llvm/lib/ExecutionEngine/Orc/LLJIT.cpp
+++ b/llvm/lib/ExecutionEngine/Orc/LLJIT.cpp
@@ -372,7 +372,7 @@ class GenericLLVMIRPlatformSupport : public LLJIT::PlatformSupport {
dbgs() << " \"" << KV.first->getName() << "\": " << KV.second << "\n";
});
- auto LookupResult = Platform::lookupInitSymbols(ES, LookupSymbols);
+ auto LookupResult = Platform::lookupResolvedInitSymbols(ES, LookupSymbols);
if (!LookupResult)
return LookupResult.takeError();
@@ -380,8 +380,10 @@ class GenericLLVMIRPlatformSupport : public LLJIT::PlatformSupport {
std::vector<ExecutorAddr> DeInitializers;
for (auto &NextJD : DFSLinkOrder) {
auto DeInitsItr = LookupResult->find(NextJD.get());
- assert(DeInitsItr != LookupResult->end() &&
- "Every JD should have at least __lljit_run_atexits");
+ // lookupResolvedInitSymbols may legitimately drop a JD whose
+ // deinit funcs (and __lljit_run_atexits) are all unmaterialized.
+ if (DeInitsItr == LookupResult->end())
+ continue;
auto RunAtExitsItr = DeInitsItr->second.find(LLJITRunAtExits);
if (RunAtExitsItr != DeInitsItr->second.end())
diff --git a/llvm/unittests/ExecutionEngine/Orc/CoreAPIsTest.cpp b/llvm/unittests/ExecutionEngine/Orc/CoreAPIsTest.cpp
index c8fcdfbb72759..b428973d11048 100644
--- a/llvm/unittests/ExecutionEngine/Orc/CoreAPIsTest.cpp
+++ b/llvm/unittests/ExecutionEngine/Orc/CoreAPIsTest.cpp
@@ -1967,4 +1967,55 @@ TEST(BootstrapJITDylibTest, EmptyBootstrapSymbols) {
cantFail(ES.endSession());
}
+TEST_F(CoreAPIsStandardTest, LookupResolvedInitSymbolsSkipsUnmaterialized) {
+ // Symbol whose materialize() must never run: represents an emitted-
+ // but-never-materialized deinit thunk.
+ std::atomic<unsigned> MaterializeCount{0};
+ auto MU = std::make_unique<SimpleMaterializationUnit>(
+ SymbolFlagsMap({{Foo, JITSymbolFlags::Exported}}),
+ [this,
+ &MaterializeCount](std::unique_ptr<MaterializationResponsibility> R) {
+ ++MaterializeCount;
+ cantFail(R->notifyResolved(
+ {{Foo, ExecutorSymbolDef(ExecutorAddr(0xfeed),
+ JITSymbolFlags::Exported)}}));
+ cantFail(R->notifyEmitted({}));
+ });
+ cantFail(JD.define(std::move(MU)));
+
+ DenseMap<JITDylib *, SymbolLookupSet> InitSyms;
+ InitSyms[&JD].add(Foo, SymbolLookupFlags::WeaklyReferencedSymbol);
+
+ auto ResolvedResult = Platform::lookupResolvedInitSymbols(ES, InitSyms);
+ ASSERT_THAT_EXPECTED(ResolvedResult, Succeeded());
+ EXPECT_EQ(MaterializeCount.load(), 0u)
+ << "lookupResolvedInitSymbols must not materialize";
+ auto It = ResolvedResult->find(&JD);
+ EXPECT_TRUE(It == ResolvedResult->end() || It->second.empty());
+
+ // Baseline: lookupInitSymbols DOES trigger materialization.
+ auto LegacyResult = Platform::lookupInitSymbols(ES, InitSyms);
+ ASSERT_THAT_EXPECTED(LegacyResult, Succeeded());
+ EXPECT_EQ(MaterializeCount.load(), 1u);
+}
+
+TEST_F(CoreAPIsStandardTest, LookupResolvedInitSymbolsReturnsResolved) {
+ cantFail(JD.define(
+ absoluteSymbols({{Foo, ExecutorSymbolDef(ExecutorAddr(0xbeef),
+ JITSymbolFlags::Exported)}})));
+
+ // Drive Foo to Ready (absoluteSymbols defines it but a real lookup is
+ // required to reach materialized state).
+ cantFail(ES.lookup({&JD}, Foo));
+
+ DenseMap<JITDylib *, SymbolLookupSet> InitSyms;
+ InitSyms[&JD].add(Foo);
+
+ auto Result = Platform::lookupResolvedInitSymbols(ES, InitSyms);
+ ASSERT_THAT_EXPECTED(Result, Succeeded());
+ ASSERT_EQ(Result->count(&JD), 1u);
+ ASSERT_EQ((*Result)[&JD].count(Foo), 1u);
+ EXPECT_EQ((*Result)[&JD][Foo].getAddress().getValue(), 0xbeefULL);
+}
+
} // namespace
More information about the cfe-commits
mailing list