Skip to content

Commit d3bd5d3

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 d3bd5d3

3 files changed

Lines changed: 316 additions & 4 deletions

File tree

lib/CppInterOp/CppInterOp.cpp

Lines changed: 95 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,91 @@ 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+
std::string DummyName = "__cppinterop_odr_use_v" +
462+
std::to_string(getInterpInfo(&I).OdrUseCounter++);
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 (llvm::Error Err =
483+
!GeneratedPTU ? GeneratedPTU.takeError() : I.Execute(*GeneratedPTU)) {
484+
llvm::consumeError(std::move(Err));
485+
return false;
486+
}
487+
#endif
488+
return true;
489+
}
490+
405491
#define DEBUG_TYPE "jitcall"
406492
bool JitCall::AreArgumentsValid(void* result, ArgList args, void* self,
407493
size_t nary) const {
@@ -2806,7 +2892,14 @@ intptr_t GetVariableOffset(compat::Interpreter& I, Decl* D,
28062892
}
28072893
if (!address) {
28082894
auto Linkage = C.GetGVALinkageForVariable(VD);
2809-
if (isDiscardableGVALinkage(Linkage))
2895+
// Odr-use emission only for discardable-ODR entities (inline/constexpr
2896+
// statics) — the class the used-list crash traced to. Internal-linkage
2897+
// variables cannot be odr-used from a later PTU (module-local symbol:
2898+
// the reference duplicates or misses the entity), and an
2899+
// available-externally definition would not be emitted by a mere
2900+
// reference; both stay on the stock UsedAttr path.
2901+
if (isDiscardableGVALinkage(Linkage) &&
2902+
(Linkage != GVA_DiscardableODR || !EmitVariableViaOdrUse(I, VD)))
28102903
ForceCodeGen(VD, I);
28112904
}
28122905
auto VDAorErr = compat::getSymbolAddress(I, StringRef(mangledName));
@@ -4989,7 +5082,7 @@ class clangSilent {
49895082
};
49905083
} // namespace
49915084

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

lib/CppInterOp/InterpreterInfo.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,14 +46,19 @@ struct InterpreterInfo {
4646
// Owns the string arguments passed to clang during creation, since the
4747
// interpreter keeps the raw argv pointers for its whole lifetime
4848
std::vector<std::string> ArgvStorage;
49+
// Names the synthesized odr-use anchors (EmitVariableViaOdrUse);
50+
// per-interpreter so a fresh interpreter's AST does not depend on
51+
// sibling interpreters' query history.
52+
unsigned OdrUseCounter = 0;
4953

5054
InterpreterInfo(compat::Interpreter* I, bool Owned,
5155
std::vector<std::string> ArgvStrs = {})
5256
: Interpreter(I), isOwned(Owned), ArgvStorage(std::move(ArgvStrs)) {}
5357

5458
InterpreterInfo(InterpreterInfo&& Other) noexcept
5559
: Interpreter(Other.Interpreter), isOwned(Other.isOwned),
56-
ArgvStorage(std::move(Other.ArgvStorage)) {
60+
ArgvStorage(std::move(Other.ArgvStorage)),
61+
OdrUseCounter(Other.OdrUseCounter) {
5762
Other.Interpreter = nullptr;
5863
Other.isOwned = false;
5964
}
@@ -64,6 +69,7 @@ struct InterpreterInfo {
6469
Interpreter = Other.Interpreter;
6570
isOwned = Other.isOwned;
6671
ArgvStorage = std::move(Other.ArgvStorage);
72+
OdrUseCounter = Other.OdrUseCounter;
6773
Other.Interpreter = nullptr;
6874
Other.isOwned = false;
6975
}

unittests/CppInterOp/VariableReflectionTest.cpp

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

3+
#include "../../lib/CppInterOp/Unwrap.h"
4+
35
#include "CppInterOp/CppInterOp.h"
6+
#include "CppInterOp/CppInterOpTypes.h"
47

58
#include "clang/AST/ASTContext.h"
9+
#include "clang/AST/Attr.h"
610
#include "clang/Basic/Version.h"
711
#include "clang/Frontend/CompilerInstance.h"
812
#include "clang/Sema/Sema.h"
913

14+
#include "llvm/Support/Error.h"
15+
1016
#include "gtest/gtest.h"
11-
#include <string>
1217

18+
#include <array>
1319
#include <cstddef>
20+
#include <string>
21+
#include <utility>
1422

1523
using namespace TestUtils;
1624
using namespace llvm;
@@ -376,6 +384,211 @@ TYPED_TEST(CPPINTEROP_TEST_MODE,
376384
EXPECT_FALSE(Cpp::GetVariableOffset(var2));
377385
}
378386

387+
TYPED_TEST(CPPINTEROP_TEST_MODE,
388+
VariableReflection_GetVariableOffset_NoStaleUsedHandle) {
389+
#ifdef __EMSCRIPTEN__
390+
// The stale-handle crash this test pins is native-JIT mechanics (ORC
391+
// freeing materialized IR), and a single failed module load poisons
392+
// Emscripten's process-global dynamic-linking state for every test that
393+
// follows (issue #1071) — not worth the fragility for a native-only
394+
// scenario.
395+
GTEST_SKIP() << "Stale-used-handle scenario is native-JIT specific";
396+
#endif
397+
// Emitting a discardable-ODR variable through the UsedAttr route records
398+
// the global as a weak handle in codegen's llvm.used list. If the global
399+
// is later replaced/erased (weak-def discard when a later PTU re-emits
400+
// the same entity), the next PTU's emitUsed dereferences the nulled
401+
// handle. The offset query must not leave used-list residue.
402+
TestFixture::CreateInterpreter();
403+
Cpp::Declare(R"(
404+
struct UsedHandle {
405+
inline static int probe = 3;
406+
};
407+
)");
408+
Cpp::DeclRef klass = Cpp::GetNamed("UsedHandle");
409+
EXPECT_TRUE(klass);
410+
Cpp::DeclRef var = Cpp::GetNamed("probe", klass);
411+
EXPECT_TRUE(var);
412+
EXPECT_TRUE(Cpp::GetVariableOffset(var));
413+
// ForceCodeGen's UsedAttr is planted permanently on the AST decl; the
414+
// odr-use route must not.
415+
EXPECT_FALSE(Cpp::unwrap<Decl>(var)->hasAttr<clang::UsedAttr>());
416+
#ifndef CPPINTEROP_USE_CLING
417+
// The UsedAttr lives on the AST decl, so every later PTU that re-emits
418+
// the entity re-adds it to that module's llvm.used — the residue the
419+
// stale-handle crash grows from. Neither a module that re-emits the
420+
// variable nor an unrelated one may carry llvm.used. (PTU/TheModule is
421+
// clang::Interpreter surface; cling covers this path via the UsedAttr
422+
// assert above.)
423+
{
424+
auto PTUOrErr = Interp->Parse("int consume_probe = UsedHandle::probe;");
425+
ASSERT_TRUE(bool(PTUOrErr));
426+
EXPECT_EQ(PTUOrErr->TheModule->getNamedGlobal("llvm.used"), nullptr);
427+
if (auto Err = Interp->Execute(*PTUOrErr))
428+
llvm::consumeError(std::move(Err));
429+
}
430+
{
431+
auto PTUOrErr = Interp->Parse("int flush_ptu = 0;");
432+
ASSERT_TRUE(bool(PTUOrErr));
433+
EXPECT_EQ(PTUOrErr->TheModule->getNamedGlobal("llvm.used"), nullptr);
434+
if (auto Err = Interp->Execute(*PTUOrErr))
435+
llvm::consumeError(std::move(Err));
436+
}
437+
EXPECT_TRUE(Cpp::GetNamed("flush_ptu"));
438+
#endif // !CPPINTEROP_USE_CLING
439+
// The crash this guards against was cumulative — many force-emitted
440+
// statics plus JIT materialization cycles, interleaved with absorbed
441+
// parse failures. Mimic that sweep shape as a regression net; the
442+
// per-index sources are assembled at compile time by stringification.
443+
struct SweepCase {
444+
const char* decl;
445+
const char* cls;
446+
const char* var;
447+
const char* probe;
448+
const char* use;
449+
};
450+
#define SWEEP_CASE(n) \
451+
{ \
452+
"struct Sweep" #n " { inline static int v" #n " = " #n "; };", "Sweep" #n, \
453+
"v" #n, "template <> struct Sweep" #n "<int>;", \
454+
"int use" #n " = Sweep" #n "::v" #n ";" \
455+
}
456+
constexpr std::array<SweepCase, 8> Sweeps = {
457+
{SWEEP_CASE(0), SWEEP_CASE(1), SWEEP_CASE(2), SWEEP_CASE(3),
458+
SWEEP_CASE(4), SWEEP_CASE(5), SWEEP_CASE(6), SWEEP_CASE(7)}};
459+
#undef SWEEP_CASE
460+
for (const SweepCase& S : Sweeps) {
461+
Cpp::Declare(S.decl);
462+
Cpp::DeclRef k = Cpp::GetNamed(S.cls);
463+
ASSERT_TRUE(k);
464+
Cpp::DeclRef v = Cpp::GetNamed(S.var, k);
465+
ASSERT_TRUE(v);
466+
EXPECT_TRUE(Cpp::GetVariableOffset(v));
467+
Cpp::Declare(S.probe, /*silent=*/true); // expected parse failure
468+
Cpp::Declare(S.use);
469+
}
470+
Cpp::Declare("int sweep_done = 1;");
471+
EXPECT_TRUE(Cpp::GetNamed("sweep_done"));
472+
473+
// Template-specialization members take the odr-use route too — the
474+
// Sema-built address-of needs no source spelling of the specialization,
475+
// and the caller has already instantiated the definition.
476+
Cpp::Declare(R"(
477+
template <typename T> struct TmplStatic {
478+
inline static int member = 7;
479+
};
480+
)");
481+
Cpp::DeclRef tmpl = Cpp::GetNamed("TmplStatic");
482+
EXPECT_TRUE(tmpl);
483+
ASTContext& C = Interp->getCI()->getASTContext();
484+
std::vector<Cpp::TemplateArgInfo> template_args = {
485+
{C.IntTy.getAsOpaquePtr()}};
486+
Cpp::DeclRef inst = Cpp::InstantiateTemplate(tmpl, template_args);
487+
EXPECT_TRUE(inst);
488+
Cpp::DeclRef member = Cpp::GetNamed("member", inst);
489+
EXPECT_TRUE(member);
490+
EXPECT_TRUE(Cpp::GetVariableOffset(member));
491+
EXPECT_FALSE(Cpp::unwrap<Decl>(member)->hasAttr<clang::UsedAttr>());
492+
493+
// Anonymous-namespace members cannot be named from a fresh chunk of
494+
// source; they bail out of the odr-use route the same way.
495+
Cpp::Declare(R"(
496+
namespace {
497+
struct AnonNsStatic {
498+
inline static int member = 9;
499+
};
500+
}
501+
)");
502+
Cpp::DeclRef anon_klass = Cpp::GetNamed("AnonNsStatic");
503+
ASSERT_TRUE(anon_klass);
504+
Cpp::DeclRef anon_member = Cpp::GetNamed("member", anon_klass);
505+
ASSERT_TRUE(anon_member);
506+
EXPECT_TRUE(Cpp::GetVariableOffset(anon_member));
507+
#ifndef CPPINTEROP_USE_CLING
508+
EXPECT_TRUE(Cpp::unwrap<Decl>(anon_member)->hasAttr<clang::UsedAttr>());
509+
#endif
510+
511+
// A linkage-spec block is transparent for naming purposes and stays on
512+
// the odr-use route.
513+
Cpp::Declare(R"(
514+
extern "C++" {
515+
struct CxxLinkStatic {
516+
inline static int member = 11;
517+
};
518+
}
519+
)");
520+
Cpp::DeclRef link_klass = Cpp::GetNamed("CxxLinkStatic");
521+
ASSERT_TRUE(link_klass);
522+
Cpp::DeclRef link_member = Cpp::GetNamed("member", link_klass);
523+
ASSERT_TRUE(link_member);
524+
EXPECT_TRUE(Cpp::GetVariableOffset(link_member));
525+
EXPECT_FALSE(Cpp::unwrap<Decl>(link_member)->hasAttr<clang::UsedAttr>());
526+
}
527+
528+
#ifndef CPPINTEROP_USE_CLING
529+
TYPED_TEST(CPPINTEROP_TEST_MODE,
530+
VariableReflection_GetVariableOffset_UnmaterializableDefinition) {
531+
// A definition whose dynamic initializer needs an undefined symbol parses
532+
// fine but cannot be materialized: the odr-use anchor's execution fails
533+
// and the query must fail cleanly. (The anchor's flush block is
534+
// clang-repl-only, hence the gate.)
535+
#ifdef __EMSCRIPTEN__
536+
// The intentional materialization failure leaves the process-global wasm
537+
// dynamic-linking state broken for every later load (see issue #1071).
538+
GTEST_SKIP() << "A failed module load poisons wasm dynamic linking";
539+
#endif
540+
#ifdef _WIN32
541+
// The intentional failure makes ORC print "JIT session error :" on
542+
// stderr, which MSBuild's canonical-error scraping promotes to a build
543+
// error (MSB8066) even though every test passes.
544+
GTEST_SKIP() << "MSBuild's canonical-error scraping treats ORC's "
545+
"JIT-session-failure stderr line as a build failure";
546+
#endif
547+
TestFixture::CreateInterpreter();
548+
Cpp::Declare(R"(
549+
int undefined_fn();
550+
struct Unmaterializable {
551+
inline static int probe = undefined_fn();
552+
};
553+
)");
554+
Cpp::DeclRef klass = Cpp::GetNamed("Unmaterializable");
555+
ASSERT_TRUE(klass);
556+
Cpp::DeclRef var = Cpp::GetNamed("probe", klass);
557+
ASSERT_TRUE(var);
558+
EXPECT_FALSE(Cpp::GetVariableOffset(var));
559+
}
560+
#endif // !CPPINTEROP_USE_CLING
561+
562+
TYPED_TEST(CPPINTEROP_TEST_MODE,
563+
VariableReflection_GetVariableOffset_OdrUseAnchorPerInterpreter) {
564+
// The synthesized odr-use anchors must be named per-interpreter: a fresh
565+
// interpreter's AST (and anything derived from it, e.g. a crash
566+
// reproducer) may not depend on how many queries a sibling interpreter
567+
// has run.
568+
Cpp::InterpRef I1 = TestFixture::CreateInterpreter();
569+
ASSERT_TRUE(I1);
570+
Cpp::Declare(R"(struct PerInterpA { inline static int probe = 1; };)");
571+
Cpp::DeclRef ka = Cpp::GetNamed("PerInterpA");
572+
ASSERT_TRUE(ka);
573+
Cpp::DeclRef va = Cpp::GetNamed("probe", ka);
574+
ASSERT_TRUE(va);
575+
EXPECT_TRUE(Cpp::GetVariableOffset(va));
576+
EXPECT_TRUE(Cpp::GetNamed("__cppinterop_odr_use_v0"));
577+
578+
Cpp::InterpRef I2 = TestFixture::CreateInterpreter();
579+
ASSERT_TRUE(I2);
580+
Cpp::Declare(R"(struct PerInterpB { inline static int probe = 2; };)");
581+
Cpp::DeclRef kb = Cpp::GetNamed("PerInterpB");
582+
ASSERT_TRUE(kb);
583+
Cpp::DeclRef vb = Cpp::GetNamed("probe", kb);
584+
ASSERT_TRUE(vb);
585+
EXPECT_TRUE(Cpp::GetVariableOffset(vb));
586+
// The second interpreter's first anchor is also its v0.
587+
EXPECT_TRUE(Cpp::GetNamed("__cppinterop_odr_use_v0"));
588+
EXPECT_FALSE(Cpp::GetNamed("__cppinterop_odr_use_v1"));
589+
EXPECT_TRUE(Cpp::DeleteInterpreter(I2));
590+
}
591+
379592
#define CODE \
380593
class BaseA { \
381594
public: \

0 commit comments

Comments
 (0)