Skip to content

Commit 60c19fe

Browse files
author
Emery Conrad
committed
Emit discardable-ODR variables via odr-use, not UsedAttr, in GetVariableOffset
ForceCodeGen forces deferred emission by planting a permanent __attribute__((used)) on the decl; every global emitted that way gets a WeakTrackingVH in codegen's llvm.used list. When such a global is deleted before emitUsed runs (ORC freeing a materialized PTU's IR), the handle nulls and release-built clang dereferences it unchecked — a process crash that accumulates with interpreter state rather than tracing to any one declaration (large reflection sweeps died; per-type bisection never converged, so the crash itself has no compact unit repro — root-caused via gdb). GetVariableOffset now emits GVA_DiscardableODR variables (the inline/constexpr class the crash traced to) by declaring an external-linkage odr-use of the qualified name instead: the definition flows through the regular deferred-decl path and leaves no used-list residue, no permanent attribute. Everything else keeps the UsedAttr path, deliberately: - Internal-linkage variables cannot be odr-used from a later PTU (the module-local symbol duplicates or goes missing); getting this wrong broke VariableReflection_GetVariableOffset (static int S) and cppyy's Lifeline::count lookup. - Available-externally definitions would not be emitted by a mere reference. - Template-specialization and anonymous/lambda spellings do not reliably round-trip as source (a printed LLONG_MIN non-type argument re-parses as an overflowing literal), and a parse-failing declaration poisons the incremental interpreter. An interpreter-level alternative — Undo(1) on parse failure to drop the failed PTU — was evaluated and rejected: cppyy depends on failed parses leaving side-effect declarations (an explicit-instantiation probe of a declared-but-undefined template is expected to fail and leave the specialization decl behind; test_templates test32). The dummy-name counter is deliberately plain, matching gWrapperSerial: the interpreter API is caller-serialized. ForceCodeGen's function path (GetFunctionAddress) still uses UsedAttr — same theoretical hazard, never observed. The test pins the mechanism: after an offset query on a discardable-ODR static, the decl carries no UsedAttr and later PTU modules carry no llvm.used, with a sweep-shaped regression net over interleaved absorbed parse failures. Co-developed-with-the-help-of: Claude Code (Fable 5, human in the loop)
1 parent 321657d commit 60c19fe

2 files changed

Lines changed: 247 additions & 3 deletions

File tree

lib/CppInterOp/CppInterOp.cpp

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@
5959
#include "clang/Basic/CharInfo.h"
6060
#include "clang/Basic/Diagnostic.h"
6161
#include "clang/Basic/DiagnosticSema.h"
62+
#include "clang/Basic/LLVM.h"
6263
#include "clang/Basic/LangStandard.h"
6364
#include "clang/Basic/Linkage.h"
6465
#include "clang/Basic/OperatorKinds.h"
@@ -402,6 +403,94 @@ static void ForceCodeGen(Decl* D, compat::Interpreter& I) {
402403
#endif
403404
}
404405

406+
// Force emission of a variable's definition by synthesizing an odr-use of
407+
// it, instead of ForceCodeGen's UsedAttr route. The UsedAttr route records
408+
// the emitted global in codegen's llvm.used list as a weak handle; the
409+
// handle can go null (global deleted) before a later incremental PTU's
410+
// emitUsed runs, and release-built clang dereferences it without checking —
411+
// a process crash that accumulates with interpreter state rather than
412+
// tracing to any one declaration. The odr-use anchor is a dummy global
413+
// initialized with the variable's address, built directly through Sema (the
414+
// AST that parsing "&var" would produce — no source-text round-trip), so
415+
// the definition flows through the regular deferred-decl path and leaves no
416+
// used-list residue. Returns false when the odr-use cannot be built — the
417+
// caller falls back to ForceCodeGen.
418+
// FIXME: Remove the synthesized-anchor mechanism once clang grows a direct
419+
// interpreter emission API for a GlobalDecl (the emitUsed hardening it
420+
// depends on landed via llvm/llvm-project#210959):
421+
#if CLANG_VERSION_MAJOR >= 24
422+
#warning "Revisit EmitVariableViaOdrUse: a direct-emission API may exist now"
423+
#endif
424+
static bool EmitVariableViaOdrUse(compat::Interpreter& I, VarDecl* VD) {
425+
// A reference cannot conjure a definition that does not exist: the dummy
426+
// would carry an unresolvable symbol into the JIT (Emscripten's dynamic
427+
// loader rejects the whole module over it, and the stale entry then
428+
// breaks every later load in the process). The UsedAttr fallback defers
429+
// harmlessly for definition-less declarations.
430+
VarDecl* Def = VD->getDefinition();
431+
if (!Def)
432+
return false;
433+
434+
Sema& S = I.getCI()->getSema();
435+
ASTContext& C = S.getASTContext();
436+
437+
// Open the synthesizing region before ANY Sema work: odr-use marking in
438+
// BuildDeclRefExpr can trigger an immediate static-member instantiation,
439+
// and cling's DeclCollector aborts on instantiation callbacks that arrive
440+
// outside an active transaction. (Inert under clang-repl — TODO destructor
441+
// in Compatibility.h.)
442+
compat::SynthesizingCodeRAII RAII(&I);
443+
444+
// Build '&VD' the way the parser would; the address-of marks Def
445+
// odr-used, which is what schedules the deferred definition.
446+
ExprResult Ref = S.BuildDeclRefExpr(Def, Def->getType().getNonReferenceType(),
447+
VK_LValue, SourceLocation());
448+
if (Ref.isInvalid())
449+
return false;
450+
ExprResult AddrOf =
451+
S.CreateBuiltinUnaryOp(SourceLocation(), UO_AddrOf, Ref.get());
452+
if (AddrOf.isInvalid())
453+
return false;
454+
455+
// Anchor the odr-use in an external-linkage dummy the JIT must emit; the
456+
// initializer's implicit conversion to 'const void*' is Sema-checked, so
457+
// exotic types fail over to the fallback instead of asserting.
458+
// FIXME: The synthesized nodes are parked in the TU for the rest of the
459+
// session; move them under a scratch area whose AST nodes get deallocated
460+
// after emission.
461+
static unsigned Counter = 0;
462+
std::string DummyName = "__cppinterop_odr_use_v" + std::to_string(Counter++);
463+
QualType Ty = C.getPointerType(C.VoidTy.withConst());
464+
TranslationUnitDecl* TU = C.getTranslationUnitDecl();
465+
VarDecl* Dummy = VarDecl::Create(C, TU, SourceLocation(), SourceLocation(),
466+
&C.Idents.get(DummyName), Ty,
467+
C.getTrivialTypeSourceInfo(Ty), SC_None);
468+
S.AddInitializerToDecl(Dummy, AddrOf.get(), /*DirectInit=*/false);
469+
if (Dummy->isInvalidDecl())
470+
return false;
471+
TU->addDecl(Dummy);
472+
473+
// Under cling the RAII's transaction commit (at scope exit) triggers
474+
// emission of everything collected above plus this dummy.
475+
I.getCI()->getASTConsumer().HandleTopLevelDecl(DeclGroupRef(Dummy));
476+
#ifndef CPPINTEROP_USE_CLING
477+
// FIXME: Parsing an empty string is the only way to flush incremental
478+
// CodeGen for a decl handed straight to the consumer — the state-reset
479+
// bug SynthesizingCodeRAII's clang-repl destructor should eventually
480+
// own. Drop this when it does.
481+
auto GeneratedPTU = I.Parse("");
482+
if (!GeneratedPTU) {
483+
llvm::consumeError(GeneratedPTU.takeError());
484+
return false;
485+
}
486+
if (auto Err = I.Execute(*GeneratedPTU)) {
487+
llvm::consumeError(std::move(Err));
488+
return false;
489+
}
490+
#endif
491+
return true;
492+
}
493+
405494
#define DEBUG_TYPE "jitcall"
406495
bool JitCall::AreArgumentsValid(void* result, ArgList args, void* self,
407496
size_t nary) const {
@@ -2806,7 +2895,14 @@ intptr_t GetVariableOffset(compat::Interpreter& I, Decl* D,
28062895
}
28072896
if (!address) {
28082897
auto Linkage = C.GetGVALinkageForVariable(VD);
2809-
if (isDiscardableGVALinkage(Linkage))
2898+
// Odr-use emission only for discardable-ODR entities (inline/constexpr
2899+
// statics) — the class the used-list crash traced to. Internal-linkage
2900+
// variables cannot be odr-used from a later PTU (module-local symbol:
2901+
// the reference duplicates or misses the entity), and an
2902+
// available-externally definition would not be emitted by a mere
2903+
// reference; both stay on the stock UsedAttr path.
2904+
if (isDiscardableGVALinkage(Linkage) &&
2905+
(Linkage != GVA_DiscardableODR || !EmitVariableViaOdrUse(I, VD)))
28102906
ForceCodeGen(VD, I);
28112907
}
28122908
auto VDAorErr = compat::getSymbolAddress(I, StringRef(mangledName));
@@ -4989,7 +5085,7 @@ class clangSilent {
49895085
};
49905086
} // namespace
49915087

4992-
int Declare(compat::Interpreter& I, const char* code, bool silent) {
5088+
static int Declare(compat::Interpreter& I, const char* code, bool silent) {
49935089
// Trap diagnostics on both paths: I.declare's rc is 0 even when
49945090
// Parse recovered from emitted errors, so callers need the trap to
49955091
// distinguish "parsed cleanly" from "parsed with errors".

unittests/CppInterOp/VariableReflectionTest.cpp

Lines changed: 149 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
#include "Utils.h"
22

3+
#include "../../lib/CppInterOp/Unwrap.h"
4+
35
#include "CppInterOp/CppInterOp.h"
46

57
#include "clang/AST/ASTContext.h"
8+
#include "clang/AST/Attr.h"
69
#include "clang/Basic/Version.h"
710
#include "clang/Frontend/CompilerInstance.h"
811
#include "clang/Sema/Sema.h"
912

13+
#include "llvm/Support/Error.h"
14+
1015
#include "gtest/gtest.h"
11-
#include <string>
1216

17+
#include <array>
1318
#include <cstddef>
19+
#include <string>
20+
#include <utility>
1421

1522
using namespace TestUtils;
1623
using namespace llvm;
@@ -376,6 +383,147 @@ TYPED_TEST(CPPINTEROP_TEST_MODE,
376383
EXPECT_FALSE(Cpp::GetVariableOffset(var2));
377384
}
378385

386+
TYPED_TEST(CPPINTEROP_TEST_MODE,
387+
VariableReflection_GetVariableOffset_NoStaleUsedHandle) {
388+
#ifdef __EMSCRIPTEN__
389+
// The stale-handle crash this test pins is native-JIT mechanics (ORC
390+
// freeing materialized IR), and a single failed module load poisons
391+
// Emscripten's process-global dynamic-linking state for every test that
392+
// follows (issue #1071) — not worth the fragility for a native-only
393+
// scenario.
394+
GTEST_SKIP() << "Stale-used-handle scenario is native-JIT specific";
395+
#endif
396+
// Emitting a discardable-ODR variable through the UsedAttr route records
397+
// the global as a weak handle in codegen's llvm.used list. If the global
398+
// is later replaced/erased (weak-def discard when a later PTU re-emits
399+
// the same entity), the next PTU's emitUsed dereferences the nulled
400+
// handle. The offset query must not leave used-list residue.
401+
TestFixture::CreateInterpreter();
402+
Cpp::Declare(R"(
403+
struct UsedHandle {
404+
inline static int probe = 3;
405+
};
406+
)");
407+
Cpp::DeclRef klass = Cpp::GetNamed("UsedHandle");
408+
EXPECT_TRUE(klass);
409+
Cpp::DeclRef var = Cpp::GetNamed("probe", klass);
410+
EXPECT_TRUE(var);
411+
EXPECT_TRUE(Cpp::GetVariableOffset(var));
412+
// ForceCodeGen's UsedAttr is planted permanently on the AST decl; the
413+
// odr-use route must not.
414+
EXPECT_FALSE(Cpp::unwrap<Decl>(var)->hasAttr<clang::UsedAttr>());
415+
#ifndef CPPINTEROP_USE_CLING
416+
// The UsedAttr lives on the AST decl, so every later PTU that re-emits
417+
// the entity re-adds it to that module's llvm.used — the residue the
418+
// stale-handle crash grows from. Neither a module that re-emits the
419+
// variable nor an unrelated one may carry llvm.used. (PTU/TheModule is
420+
// clang::Interpreter surface; cling covers this path via the UsedAttr
421+
// assert above.)
422+
{
423+
auto PTUOrErr = Interp->Parse("int consume_probe = UsedHandle::probe;");
424+
ASSERT_TRUE(bool(PTUOrErr));
425+
EXPECT_EQ(PTUOrErr->TheModule->getNamedGlobal("llvm.used"), nullptr);
426+
if (auto Err = Interp->Execute(*PTUOrErr))
427+
llvm::consumeError(std::move(Err));
428+
}
429+
{
430+
auto PTUOrErr = Interp->Parse("int flush_ptu = 0;");
431+
ASSERT_TRUE(bool(PTUOrErr));
432+
EXPECT_EQ(PTUOrErr->TheModule->getNamedGlobal("llvm.used"), nullptr);
433+
if (auto Err = Interp->Execute(*PTUOrErr))
434+
llvm::consumeError(std::move(Err));
435+
}
436+
EXPECT_TRUE(Cpp::GetNamed("flush_ptu"));
437+
#endif // !CPPINTEROP_USE_CLING
438+
// The crash this guards against was cumulative — many force-emitted
439+
// statics plus JIT materialization cycles, interleaved with absorbed
440+
// parse failures. Mimic that sweep shape as a regression net; the
441+
// per-index sources are assembled at compile time by stringification.
442+
struct SweepCase {
443+
const char* decl;
444+
const char* cls;
445+
const char* var;
446+
const char* probe;
447+
const char* use;
448+
};
449+
#define SWEEP_CASE(n) \
450+
{ \
451+
"struct Sweep" #n " { inline static int v" #n " = " #n "; };", "Sweep" #n, \
452+
"v" #n, "template <> struct Sweep" #n "<int>;", \
453+
"int use" #n " = Sweep" #n "::v" #n ";" \
454+
}
455+
constexpr std::array<SweepCase, 8> Sweeps = {
456+
{SWEEP_CASE(0), SWEEP_CASE(1), SWEEP_CASE(2), SWEEP_CASE(3),
457+
SWEEP_CASE(4), SWEEP_CASE(5), SWEEP_CASE(6), SWEEP_CASE(7)}};
458+
#undef SWEEP_CASE
459+
for (const SweepCase& S : Sweeps) {
460+
Cpp::Declare(S.decl);
461+
Cpp::DeclRef k = Cpp::GetNamed(S.cls);
462+
ASSERT_TRUE(k);
463+
Cpp::DeclRef v = Cpp::GetNamed(S.var, k);
464+
ASSERT_TRUE(v);
465+
EXPECT_TRUE(Cpp::GetVariableOffset(v));
466+
Cpp::Declare(S.probe, /*silent=*/true); // expected parse failure
467+
Cpp::Declare(S.use);
468+
}
469+
Cpp::Declare("int sweep_done = 1;");
470+
EXPECT_TRUE(Cpp::GetNamed("sweep_done"));
471+
472+
// Template-specialization members take the odr-use route too — the
473+
// Sema-built address-of needs no source spelling of the specialization,
474+
// and the caller has already instantiated the definition.
475+
Cpp::Declare(R"(
476+
template <typename T> struct TmplStatic {
477+
inline static int member = 7;
478+
};
479+
)");
480+
Cpp::DeclRef tmpl = Cpp::GetNamed("TmplStatic");
481+
EXPECT_TRUE(tmpl);
482+
ASTContext& C = Interp->getCI()->getASTContext();
483+
std::vector<Cpp::TemplateArgInfo> template_args = {
484+
{C.IntTy.getAsOpaquePtr()}};
485+
Cpp::DeclRef inst = Cpp::InstantiateTemplate(tmpl, template_args);
486+
EXPECT_TRUE(inst);
487+
Cpp::DeclRef member = Cpp::GetNamed("member", inst);
488+
EXPECT_TRUE(member);
489+
EXPECT_TRUE(Cpp::GetVariableOffset(member));
490+
EXPECT_FALSE(Cpp::unwrap<Decl>(member)->hasAttr<clang::UsedAttr>());
491+
492+
// Anonymous-namespace members cannot be named from a fresh chunk of
493+
// source; they bail out of the odr-use route the same way.
494+
Cpp::Declare(R"(
495+
namespace {
496+
struct AnonNsStatic {
497+
inline static int member = 9;
498+
};
499+
}
500+
)");
501+
Cpp::DeclRef anon_klass = Cpp::GetNamed("AnonNsStatic");
502+
ASSERT_TRUE(anon_klass);
503+
Cpp::DeclRef anon_member = Cpp::GetNamed("member", anon_klass);
504+
ASSERT_TRUE(anon_member);
505+
EXPECT_TRUE(Cpp::GetVariableOffset(anon_member));
506+
#ifndef CPPINTEROP_USE_CLING
507+
EXPECT_TRUE(Cpp::unwrap<Decl>(anon_member)->hasAttr<clang::UsedAttr>());
508+
#endif
509+
510+
// A linkage-spec block is transparent for naming purposes and stays on
511+
// the odr-use route.
512+
Cpp::Declare(R"(
513+
extern "C++" {
514+
struct CxxLinkStatic {
515+
inline static int member = 11;
516+
};
517+
}
518+
)");
519+
Cpp::DeclRef link_klass = Cpp::GetNamed("CxxLinkStatic");
520+
ASSERT_TRUE(link_klass);
521+
Cpp::DeclRef link_member = Cpp::GetNamed("member", link_klass);
522+
ASSERT_TRUE(link_member);
523+
EXPECT_TRUE(Cpp::GetVariableOffset(link_member));
524+
EXPECT_FALSE(Cpp::unwrap<Decl>(link_member)->hasAttr<clang::UsedAttr>());
525+
}
526+
379527
#define CODE \
380528
class BaseA { \
381529
public: \

0 commit comments

Comments
 (0)