Repository navigation
[NativeAOT, Linux] Lock statics initialization re-enters UTF8Encoding's class constructor and fails fast #136031
Description
Description
On Linux, a NativeAOT library can fail fast with an unhandled TypeInitializationException when two threads run class constructors at the same time and one of them is inside UTF8Encoding's class constructor.
Lock.TryInitializeStatics runs the first time a Lock is contended. That includes the lock ClassConstructorRunner takes in DeadlockAwareAcquire. It calls DetermineMaxSpinCount → AppContextConfigHelper.GetInt16Config → Environment.GetEnvironmentVariable. On Unix, that marshals the variable name with Utf8StringMarshaller, which needs UTF8Encoding's statics.
If the contending thread is itself running UTF8Encoding..cctor, the class-constructor check returns at once, because the constructor is already running on the same thread. The static is still null, the marshaller dereferences it, and the resulting exception fails the process. TryInitializeStatics already avoids Environment.ProcessorCount for exactly this kind of cycle, but the environment-variable read has the same problem.
The easiest way to hit this is a library whose first UTF-8 work races with starting a named thread. Setting the name (RhSetCurrentThreadName) marshals it to UTF-8 on the new thread, so the new thread runs UTF8Encoding..cctor.
We ran into this in Apache Arrow ADBC (apache/arrow-adbc#4917). A NativeAOT-compiled ADBC driver started a timer (Task.Delay) while its host was still calling into it, and the test host aborted on every run on ubuntu-latest.
Reproduction Steps
Repro.csproj:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<OutputType>Library</OutputType>
<PublishAot>true</PublishAot>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
</PropertyGroup>
</Project>Exports.cs:
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
public static unsafe class Exports
{
// mode 0: start a named thread while this thread does its first UTF-8 operation.
// mode 1: same, but the thread has no name.
// mode 2: same as 0, but initialize Encoding.UTF8 first.
[UnmanagedCallersOnly(EntryPoint = "repro")]
public static int Repro(int mode, byte* text)
{
if (mode == 2)
{
_ = Encoding.UTF8;
}
var thread = new Thread(() => { }) { IsBackground = true };
if (mode != 1)
{
thread.Name = "worker";
}
thread.Start();
string s = Marshal.PtrToStringUTF8((nint)text)!;
thread.Join();
return s.Length;
}
}host.c:
#include <dlfcn.h>
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char** argv) {
void* h = dlopen(argv[1], RTLD_NOW);
if (!h) { fprintf(stderr, "%s\n", dlerror()); return 2; }
int (*repro)(int, const char*) = (int (*)(int, const char*))dlsym(h, "repro");
printf("%d\n", repro(atoi(argv[2]), "hello"));
return 0;
}dotnet publish Repro.csproj -c Release -r linux-x64 -o out
clang host.c -o out/host -ldl
for m in 0 1 2; do
ok=0; bad=0
for i in $(seq 1 200); do
if out/host $PWD/out/Repro.so $m >/dev/null 2>&1; then ok=$((ok+1)); else bad=$((bad+1)); fi
done
echo "mode=$m ok=$ok crashed=$bad"
doneExpected behavior
The library returns 5 every time.
Actual behavior
Mode 0 aborts (SIGABRT, exit code 134) in most runs, and nothing is written to stderr:
mode=0 ok=56 crashed=144
mode=1 ok=200 crashed=0
mode=2 ok=200 crashed=0
Crashed thread (the new thread). This was symbolized from a core dump, and these frames run in calling order. LLDB can't unwind through the managed exception dispatch, so the frames below the fault were recovered by scanning the stack for return addresses.
StartThread
RhSetCurrentThreadName
Utf8StringMarshaller.ManagedToUnmanagedIn.FromManaged
ClassConstructorRunner.CheckStaticClassConstructionReturnGCStaticBase
ClassConstructorRunner.EnsureClassConstructorRun
UTF8Encoding..cctor (UTF8Encoding.cs:62)
DecoderReplacementFallback..ctor (DecoderReplacementFallback.cs:61)
ClassConstructorRunner.CheckStaticClassConstructionReturnGCStaticBase
ClassConstructorRunner.EnsureClassConstructorRun
ClassConstructorRunner.DeadlockAwareAcquire
Lock.TryEnterSlow
Lock.TryInitializeStatics (Lock.NativeAot.cs:204)
AppContextConfigHelper.GetInt16Config
Environment.GetEnvironmentVariableCore
Interop.Sys.GetEnv
Utf8StringMarshaller.ManagedToUnmanagedIn.FromManaged <-- null dereference of UTF8Encoding's static instance
-> RhThrowHwEx -> TryInitializeStatics catch/rethrow -> EnsureClassConstructorRun
-> TypeInitializationException -> UnhandledExceptionFailFastViaClasslib -> RhFailFast -> abort
The faulting instruction is the null check on the UTF8Encoding GC static (cmpl %edi, (%rdi) after loading the static from __GCSTATICS…UTF8Encoding) in Utf8StringMarshaller.cs:97.
The other thread (the caller) is waiting on the same path, behind the constructor the new thread is running:
Repro_Exports__Repro (Exports.cs:23)
Marshal.PtrToStringUTF8 (Marshal.cs:77)
ClassConstructorRunner.CheckStaticClassConstructionReturnGCStaticBase
ClassConstructorRunner.EnsureClassConstructorRun
ClassConstructorRunner.DeadlockAwareAcquire
Lock.TryEnter_Outlined
Lock.TryEnterSlow
Lock.TryInitializeStatics (Lock.NativeAot.cs:204)
AppContextConfigHelper.GetInt16Config
Environment.GetEnvironmentVariableCore
Interop.Sys.GetEnv
Utf8StringMarshaller.ManagedToUnmanagedIn.FromManaged
ClassConstructorRunner.CheckStaticClassConstructionReturnGCStaticBase
ClassConstructorRunner.EnsureClassConstructorRun
ClassConstructorRunner.DeadlockAwareAcquire
Lock.TryEnter_Outlined
Lock.TryEnterSlow -> Thread.Yield
Regression?
Unknown. The environment-variable read in TryInitializeStatics is present in release/10.0, and on main as of 2026-10-18 (where the code is now in Lock.cs).
Known Workarounds
Initialize Encoding.UTF8 on the calling thread before the library can start any threads, for example in its first exported entry point. With that, mode 2 never crashed in 200 runs. In the ADBC test suite, it took the AOT fixture tests from crashing 8 runs out of 8 to passing 15 out of 15.
Configuration
- .NET SDK 10.0.402; runtime and ILCompiler 10.0.13
- linux-x64. Reproduced on Ubuntu 26.04 (WSL2) and on GitHub Actions
ubuntu-latest. - The same code doesn't crash on Windows or macOS in our CI. Presumably that is because
GetEnvironmentVariabledoesn't go through UTF-8 marshalling on Windows. I haven't checked why macOS is unaffected. - The host process doesn't matter: the plain C host above reproduces it, and so does a CoreCLR (xUnit) test host loading the library.
Other information
A possible fix: make the configuration lookup in Lock.TryInitializeStatics safe against static-constructor cycles, as was already done for Environment.ProcessorCount. For example, read DOTNET_Lock_SpinCount through a path that doesn't need UTF8Encoding, or treat a failure there as "use the default" and set the stage to PartiallyComplete.
Metadata
Metadata
Assignees
Labels
Type
Projects
- StatusShow more project fieldsNo status
Activity
dotnet-policy-service commentedon Oct 18, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @VSadov
See info in area-owners.md if you want to be subscribed.
feat(csharp): collect garbage when an exported driver goes idle (#4917)
dotnet-policy-service commentedon Oct 19, 2026
Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
See info in area-owners.md if you want to be subscribed.