Skip to content

[NativeAOT, Linux] Lock statics initialization re-enters UTF8Encoding's class constructor and fails fast #136031

Open

Description

@CurtHagenlocher

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"
done

Expected 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 GetEnvironmentVariable doesn'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.

Activity

  1. dotnet-policy-service commented on Oct 18, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @VSadov
    See info in area-owners.md if you want to be subscribed.

  2. dotnet-policy-service commented on Oct 19, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @agocke, @dotnet/ilc-contrib
    See info in area-owners.md if you want to be subscribed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    • Status
      No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions