Checked build JIT assertion (`pfnHelper != NULL`) when accessing `__declspec(thread)` TLS from IJW/C++CLI managed entry points
- Dominant language
- C#
- Stars
- 18.3k
- Forks
- 5.6k
- PR merge metrics
- PR metrics pending
Description
### Description
When a mixed-mode (IJW/C++/CLI) assembly declares a native, file-scope `__declspec(thread)` global (wrapped in `#pragma unmanaged`/`#pragma managed`) and that global is accessed from a `public ref class` static method, calling into that method from managed code under a **Checked** CoreCLR build crashes with a JIT assertion:
```
Assert failure(PID ####): pfnHelper != (PCODE)NULL
CORECLR! CEECodeGenInfo::getHelperFtn + 0x4E7
CLRJIT! Compiler::compGetHelperFtn + 0x35
CLRJIT! Lowering::LowerDirectCall + 0xA5
CLRJIT! Lowering::LowerCall + 0x429
CLRJIT! Lowering::LowerNode + 0x32C
CLRJIT! Lowering::DoPhase + 0x13B
CLRJIT! Phase::Run + 0x76
CLRJIT! Compiler::compCompile + 0x1AD2
CLRJIT! Compiler::compCompileHelper + 0xC51
CLRJIT! Compiler::compCompileAfterInit + 0x7B1
File: src\coreclr\vm\jitinterface.cpp:11353
```
The assertion fires in `CEECodeGenInfo::getHelperFtn` (`src/coreclr/vm/jitinterface.cpp:11404`, in a `FEATURE_PORTABLE_ENTRYPOINTS` code path), where the JIT's `Lowering::LowerDirectCall` requests a JIT helper function pointer and the helper table entry is unpopulated.
### Repro
Minimal repro (Windows, x64, Checked build):
`test.cpp` (compiled as an IJW/C++CLI assembly, e.g. via `/clr`):
```cpp
#pragma unmanaged
__declspec(thread) int s_tlsFieldData = 51966;
#pragma managed
public ref class TlsTest
{
public:
static int Test()
{
int value = s_tlsFieldData;
s_tlsFieldData = value;
s_tlsFieldData = 100;
return s_tlsFieldData;
}
};
```
Managed caller (any invocation mechanism — both plain reflection `MethodInfo.Invoke` and `UnsafeAccessor` reproduce identically):
```csharp
Assembly a = Assembly.Load("TestTLSNative");
int result = (int)a.GetType("TlsTest").GetMethod("Test").Invoke(null, null);
```
Running this against a Checked CoreCLR build crashes with the assertion above. The same code runs successfully against Debug and Release builds. Switching the native storage from `__declspec(thread)` to a managed `[System::ThreadStatic]` field on the `ref class` avoids the crash entirely.
### Analysis so far
- The crash is independent of the managed→native call mechanism: it reproduces identically whether the native method is invoked via reflection (`MethodInfo.Invoke`) or via `[UnsafeAccessor]`/`[UnsafeAccessorType]`.
- The crash is tied specifically to `__declspec(thread)` TLS storage accessed through the IJW-hosted managed entry point; using `[System::ThreadStatic]` instead does not reproduce the crash.
- This was discovered while converting `src/tests` regression tests that originally used IL `.data tls` declarations to C++/CLI (see dotnet/runtime#133533), where a reviewer requested using true native `__declspec(thread)` TLS (rather than `[System::ThreadStatic]`) "to actually be testing the right feature." Reintroducing `__declspec(thread)` reproduced this crash in all four converted tests.
### Configuration
- OS: Windows x64
- Build: Checked
- Repro requires `/clr` (IJW/C++CLI) mixed-mode assembly
> [!NOTE]
> This issue was filed with assistance from GitHub Copilot.
Contributor guide
Research direction
Reproduce the Windows x64 Checked-build failure with the provided C++/CLI IJW sample and managed caller. Inspect src/coreclr/vm/jitinterface.cpp around CEECodeGenInfo::getHelperFtn and the FEATURE_PORTABLE_ENTRYPOINTS helper-table path, then compare the __declspec(thread) case with [System::ThreadStatic]. Done means the assertion no longer occurs and the regression scenario runs successfully.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- cpp, csharp
- Domain
- compilers, operating-systems
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 45/100