Skip to content

Attempt to execute managed code after the .NET runtime thread state has been destroyed #117538

@stdcion

Description

@stdcion

Description

Issue occurs when using Fiber Local Storage (FLS) with .NET 9.0.5 and later on Windows x64 platforms.
The same code works correctly in .NET 9.0.4 and earlier versions (include .NET 8.0, .NET 7.0, .NET 6.0,
.NET Framework 4.5, etc.)

Any use of the FLS API results in a crash when the callback is invoked, with the following assertion failure:

CLR: Assert failure(PID 54444 [0x0000d4ac], Thread: 13976 [0x3698]): !"Attempt to execute managed code after the .NET runtime thread state has been destroyed."
    File: D:\a\_work\1\s\src\coreclr\vm\ceemain.cpp:1799 Image:
C:\Program Files\dotnet\dotnet.exe

A little clarification, in my project, I'm using a native library that requires calling a Detach method when the thread terminates. I implemented this using the FLS API, which previously worked without issues.

I would greatly appreciate any guidance on:

  • Whether I'm implementing this correctly.
  • If there's a recommended alternative approach for this in .NET 9.0.5+
  • Any known workarounds for this issue.

Please don't hesitate to contact me if you require any additional information.

Reproduction Steps

I've prepared a public repository with a minimal project and steps for reproduction:
https://github.com/stdcion/win-fls-net9-bug

using System.Runtime.InteropServices;

namespace win_fls_net9_bug;

internal static class Program
{
    private static readonly ThreadExitCallback OnThreadExit = DoSomethingOnThreadExit;
    private static readonly int ThreadExitCallbackId = RegisterCallbackDelegate(OnThreadExit);

    [UnmanagedFunctionPointer(CallingConvention.Cdecl)]
    private delegate void ThreadExitCallback(nint threadLocalValue);

    private static void Main(string[] args)
    {
        Console.WriteLine($".NET Version: {RuntimeInformation.FrameworkDescription}");

        var thread = new Thread(() =>
        {
            Console.WriteLine("Thread started");
            var handle = GCHandle.Alloc(42, GCHandleType.Pinned);
            // Register callback to be invoked when current thread exits.
            EnableThreadExitCallbackForCurrentThread(ThreadExitCallbackId, GCHandle.ToIntPtr(handle));
        }) { IsBackground = false };
        thread.Start();
        thread.Join(); // Wait for the thread to complete and callback to execute.
    }

    private static void DoSomethingOnThreadExit(nint threadLocalValue)
    {
        // The content of the method doesn't matter,
        // I can register an empty method, or a reference to a native method, the result is the same:
        // Attempt to execute managed code after the .NET runtime thread state has been destroyed.
        var handle = GCHandle.FromIntPtr(threadLocalValue);
        Console.WriteLine("Thread exit, thread local value: " + handle.Target);
        handle.Free();
    }

    private static int RegisterCallbackDelegate(ThreadExitCallback callback)
    {
        var callbackPtr = Marshal.GetFunctionPointerForDelegate(callback);
        var callbackId = WindowsImport.FlsAlloc(callbackPtr);

        if (callbackId == WindowsImport.FlsOutOfIndexes)
        {
            throw new InvalidOperationException("FlsAlloc failed: " + Marshal.GetLastWin32Error());
        }

        return callbackId;
    }

    private static void EnableThreadExitCallbackForCurrentThread(int callbackId, nint threadLocalValue)
    {
        var result = WindowsImport.FlsSetValue(callbackId, threadLocalValue);

        if (!result)
        {
            throw new InvalidOperationException("FlsSetValue failed: " + Marshal.GetLastWin32Error());
        }
    }

    /// <summary>
    /// P/Invoke declarations for Windows FLS (Fiber Local Storage) APIs.
    /// FLS provides thread-local storage with destructor callbacks.
    /// </summary>
    private static class WindowsImport
    {
        public const string DllName = "kernel32.dll";

        public const int FlsOutOfIndexes = -1;

        [DllImport(DllName, SetLastError = true)]
        public static extern int FlsAlloc(nint destructorCallback);

        [DllImport(DllName, SetLastError = true)]
        [return: MarshalAs(UnmanagedType.Bool)]
        public static extern bool FlsSetValue(int key, nint threadLocalValue);

    }
}

How to run:

dotnet build --configuration Release
cd win-fls-net9-bug\bin\Release\net9.0\
dotnet --fx-version 9.0.4 .\win-fls-net9-bug.dll # works correctly
dotnet --fx-version 9.0.5 .\win-fls-net9-bug.dll # fails

Expected behavior

Assert failure does not occur:

> dotnet --fx-version 9.0.5 .\win-fls-net9-bug.dll
.NET Version: .NET 9.0.5
Thread started
Thread exit, thread local value: 42

Actual behavior

Assert failure:

> dotnet --fx-version 9.0.5 .\win-fls-net9-bug.dll
.NET Version: .NET 9.0.5
Thread started
CLR: Assert failure(PID 54444 [0x0000d4ac], Thread: 13976 [0x3698]): !"Attempt to execute managed code after the .NET runtime thread state has been destroyed."
    File: D:\a\_work\1\s\src\coreclr\vm\ceemain.cpp:1799 Image:
C:\Program Files\dotnet\dotnet.exe

Regression?

  • Works correctly in .NET 9.0.4 and earlier versions (include .NET 8.0, .NET 7.0, .NET 6.0, .NET Framework 4.5, etc.)
  • Does not work since .NET 9.0.5

Known Workarounds

I don't know, I would really appreciate it if you could suggest some workaround.

Configuration

  • .NET Version: 9.0.5 and newer
  • OS: Windows 11 Pro 24H2, Build: 26100.4349
  • Arch: x64
  • Release or Debug configuration does not matter

Other information

It's most likely related to this PR: #112809
with this assertion:

static void OsAttachThread(void* thread)
{
_ASSERTE(g_flsIndex != FLS_OUT_OF_INDEXES);
if (t_flsState == FLS_STATE_INVOKED)
{
_ASSERTE_ALL_BUILDS(!"Attempt to execute managed code after the .NET runtime thread state has been destroyed.");
}
t_flsState = FLS_STATE_ARMED;
// Associate the current fiber with the current thread. This makes the current fiber the thread's "home"
// fiber. This fiber is the only fiber allowed to execute managed code on this thread. When this fiber
// is destroyed, we consider the thread to be destroyed.
_ASSERTE(thread != NULL);
FlsSetValue(g_flsIndex, thread);
}

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Relationships

None yet

Development

No branches or pull requests

Issue actions