diff --git a/src/coreclr/vm/ceemain.cpp b/src/coreclr/vm/ceemain.cpp index 0165ff014ebe2a..dbf5d2602faa06 100644 --- a/src/coreclr/vm/ceemain.cpp +++ b/src/coreclr/vm/ceemain.cpp @@ -224,6 +224,10 @@ HRESULT EEStartup(); static void InitializeGarbageCollector(); +#ifdef TARGET_APPLE +static void InitThreadStateKey(); +#endif + #ifdef DEBUGGING_SUPPORTED static void InitializeDebugger(void); static void TerminateDebugger(void); @@ -710,6 +714,10 @@ void EEStartupHelper() InitThreadManager(); STRESS_LOG0(LF_STARTUP, LL_ALWAYS, "Returned successfully from InitThreadManager"); +#ifdef TARGET_APPLE + InitThreadStateKey(); +#endif + #ifdef FEATURE_PERFTRACING // Initialize the event pipe. EventPipeAdapter::Initialize(); @@ -1704,6 +1712,11 @@ static uint32_t g_flsIndex = FLS_OUT_OF_INDEXES; static PLATFORM_THREAD_LOCAL byte t_flsState; +static bool HasThreadStateBeenDestroyed() +{ + return t_flsState == FLS_STATE_INVOKED; +} + // This is called when each *fiber* is destroyed. When the home fiber of a thread is destroyed, // it means that the thread itself is destroyed. // Since we receive that notification outside of the Loader Lock, it allows us to safely acquire @@ -1733,27 +1746,12 @@ void InitFlsSlot() } // Register the thread with OS to be notified when thread is about to be destroyed -// It fails fast if a different thread was already registered with the current fiber. // Parameters: // thread - thread to attach static void OsAttachThread(void* thread) { _ASSERTE(g_flsIndex != FLS_OUT_OF_INDEXES); - - if (t_flsState == FLS_STATE_INVOKED) - { - // Managed C++ may run managed code in DllMain (e.g. during DLL_PROCESS_DETACH to run global destructors). This is - // not supported and unreliable. Historically, it happened to work most of the time. For backward compatibility, - // suppress this assert in release builds if we have encountered any mixed mode binaries. - if (Module::HasAnyIJWBeenLoaded()) - { - _ASSERTE(!"Attempt to execute managed code after the .NET runtime thread state has been destroyed."); - } - else - { - _ASSERTE_ALL_BUILDS(!"Attempt to execute managed code after the .NET runtime thread state has been destroyed."); - } - } + _ASSERTE(t_flsState == FLS_STATE_CLEAR); t_flsState = FLS_STATE_ARMED; @@ -1798,6 +1796,54 @@ void EnsureTlsDestructionMonitor() } #else +#ifdef TARGET_APPLE +static pthread_key_t g_threadStateKey; +static byte g_threadStateDestroyedMarker; + +static void SetThreadStateDestroyed() +{ + if (pthread_setspecific(g_threadStateKey, &g_threadStateDestroyedMarker) != 0) + { + _ASSERTE_ALL_BUILDS(!"Failed to preserve the destroyed runtime thread state."); + } +} + +// POSIX clears a key before invoking its destructor, and key destructor order is unspecified. +// Restore the marker so managed re-entry from later destructors can observe the destroyed state. +static void ThreadStateKeyDestructor(void* state) +{ + if (state == &g_threadStateDestroyedMarker) + { + SetThreadStateDestroyed(); + } +} + +static void InitThreadStateKey() +{ + if (pthread_key_create(&g_threadStateKey, ThreadStateKeyDestructor) != 0) + { + COMPlusThrowOM(); + } +} + +static bool HasThreadStateBeenDestroyed() +{ + return pthread_getspecific(g_threadStateKey) == &g_threadStateDestroyedMarker; +} +#else +static PLATFORM_THREAD_LOCAL bool t_threadStateDestroyed; + +static bool HasThreadStateBeenDestroyed() +{ + return t_threadStateDestroyed; +} + +static void SetThreadStateDestroyed() +{ + t_threadStateDestroyed = true; +} +#endif + struct TlsDestructionMonitor { bool m_activated = false; @@ -1813,6 +1859,8 @@ struct TlsDestructionMonitor { RuntimeThreadShutdown(GetThreadNULLOk()); } + + SetThreadStateDestroyed(); } }; @@ -1827,6 +1875,26 @@ void EnsureTlsDestructionMonitor() #endif +void CheckThreadStateNotDestroyed() +{ + if (!HasThreadStateBeenDestroyed()) + { + return; + } + + // Managed C++ may run managed code in DllMain (e.g. during DLL_PROCESS_DETACH to run global destructors). This is + // not supported and unreliable. Historically, it happened to work most of the time. For backward compatibility, + // suppress this assert in release builds if we have encountered any mixed mode binaries. + if (Module::HasAnyIJWBeenLoaded()) + { + _ASSERTE(!"Attempt to execute managed code after the .NET runtime thread state has been destroyed."); + } + else + { + _ASSERTE_ALL_BUILDS(!"Attempt to execute managed code after the .NET runtime thread state has been destroyed."); + } +} + #ifdef DEBUGGING_SUPPORTED // // InitializeDebugger initialized the Runtime-side CLR Debugging Services diff --git a/src/coreclr/vm/ceemain.h b/src/coreclr/vm/ceemain.h index 120082d30572ca..e518f2889d987b 100644 --- a/src/coreclr/vm/ceemain.h +++ b/src/coreclr/vm/ceemain.h @@ -46,6 +46,9 @@ void ForceEEShutdown(ShutdownCompleteAction sca = SCA_ExitProcessWhenShutdownCom void ThreadDetaching(); void EnsureTlsDestructionMonitor(); + +// Fails fast if the runtime thread state of the current thread has already been destroyed. +void CheckThreadStateNotDestroyed(); #ifdef TARGET_WINDOWS void InitFlsSlot(); void OsDetachThread(void* thread); diff --git a/src/coreclr/vm/threads.cpp b/src/coreclr/vm/threads.cpp index e59b3dfb16e8b4..de603b98add407 100644 --- a/src/coreclr/vm/threads.cpp +++ b/src/coreclr/vm/threads.cpp @@ -605,6 +605,8 @@ Thread* SetupThread() if ((pThread = GetThreadNULLOk()) != NULL) return pThread; + CheckThreadStateNotDestroyed(); + // For interop debugging, we must mark that we're in a can't-stop region // b.c we may take Crsts here that may block the helper thread. // We're especially fragile here b/c we don't have a Thread object yet diff --git a/src/tests/baseservices/threading/ThreadStateDestroyed/CMakeLists.txt b/src/tests/baseservices/threading/ThreadStateDestroyed/CMakeLists.txt new file mode 100644 index 00000000000000..d64070b3f9bbe7 --- /dev/null +++ b/src/tests/baseservices/threading/ThreadStateDestroyed/CMakeLists.txt @@ -0,0 +1,13 @@ +project (ThreadStateDestroyedNative) + +include_directories(${INC_PLATFORM_DIR}) + +set(SOURCES ThreadStateDestroyedNative.cpp) + +if(NOT CLR_CMAKE_HOST_WIN32) + add_compile_options(-pthread) +endif() + +add_library (ThreadStateDestroyedNative ${TEST_LIB_TYPE} ${SOURCES}) + +install (TARGETS ThreadStateDestroyedNative DESTINATION bin) diff --git a/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs b/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs new file mode 100644 index 00000000000000..e44643809bc726 --- /dev/null +++ b/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs @@ -0,0 +1,125 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Diagnostics; +using System.Runtime.InteropServices; +using System.Threading; +using TestLibrary; + +// A native library runs managed code on an OS thread, then runs managed code again from a +// thread-destruction callback that fires after the runtime has already torn down its per-thread +// state. The runtime should fail fast instead of attaching another managed thread. + +public static unsafe class ThreadStateDestroyed +{ + private const int Pass = 100; + private const int Fail = -1; + + private const string NativeLib = "ThreadStateDestroyedNative"; + private const string RunScenarioArg = "--run-scenario"; + + private static readonly TimeSpan s_subprocessTimeout = TimeSpan.FromSeconds(60); + + // In non-Release builds, also check the message to distinguish this failure from other crashes. + private const string ExpectedMessage = + "Attempt to execute managed code after the .NET runtime thread state has been destroyed."; + private const string SecondCallbackMarker = "[managed] callback #2"; + + [DllImport(NativeLib)] + private static extern void RunCallbackOnThreadAndDuringItsDestruction(delegate* unmanaged callback); + + private static int s_callbackCount; + private static Thread s_firstThread; + + [UnmanagedCallersOnly] + private static void Callback() + { + int count = Interlocked.Increment(ref s_callbackCount); + Thread current = Thread.CurrentThread; + + if (count == 1) + { + s_firstThread = current; + Console.WriteLine("[managed] callback #1 ran; the runtime attached a Thread to the OS thread."); + } + else + { + // Managed thread ids can be recycled, so compare the Thread objects themselves. + bool newThread = !ReferenceEquals(current, s_firstThread); + Console.WriteLine($"{SecondCallbackMarker} ran; attached to a new Thread: {newThread}."); + } + } + + public static int Main(string[] args) + { + return args.Length > 0 && args[0] == RunScenarioArg + ? RunScenario() + : RunController(); + } + + private static int RunScenario() + { + // Ensure that the OS doesn't generate core dump for this intentionally crashing process + Utilities.DisableOSCoreDump(); + + RunCallbackOnThreadAndDuringItsDestruction(&Callback); + + Console.WriteLine("[managed] The runtime did not fail fast."); + return Fail; + } + + private static int RunController() + { + string[] arguments = TestLibrary.Utilities.IsNativeAot + ? [RunScenarioArg] + : [typeof(ThreadStateDestroyed).Assembly.Location, RunScenarioArg]; + + ProcessStartInfo psi = new ProcessStartInfo(Environment.ProcessPath, arguments) + { + RedirectStandardOutput = true, + RedirectStandardError = true, + }; + + psi.Environment["DOTNET_DbgEnableMiniDump"] = "0"; + psi.Environment["DOTNET_EnableCrashReport"] = "0"; + + ProcessTextOutput subprocess; + try + { + subprocess = Process.RunAndCaptureText(psi, s_subprocessTimeout); + } + catch (TimeoutException) + { + Console.WriteLine($"Subprocess timed out after {s_subprocessTimeout}."); + return Fail; + } + + string output = subprocess.StandardOutput + subprocess.StandardError; + int exitCode = subprocess.ExitStatus.ExitCode; + + Console.WriteLine($"Subprocess exited with {exitCode}:"); + Console.WriteLine(output); + + if (output.Contains(SecondCallbackMarker)) + { + Console.WriteLine("The runtime ran managed code on a thread whose runtime thread state was already destroyed."); + return Fail; + } + + if (output.Contains("[managed] The runtime did not fail fast.") || + !output.Contains("[native] invoking managed callback from the thread destruction callback")) + { + Console.WriteLine("The test scenario did not reach the expected thread-destruction failure."); + return Fail; + } + + if ((!OperatingSystem.IsWindows() || !TestLibrary.CoreClrConfigurationDetection.IsReleaseRuntime) && !output.Contains(ExpectedMessage)) + { + Console.WriteLine($"The subprocess terminated for some other reason. Expected to find: {ExpectedMessage}"); + return Fail; + } + + return Pass; + } +} diff --git a/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.csproj b/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.csproj new file mode 100644 index 00000000000000..bd6b31acc80d27 --- /dev/null +++ b/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.csproj @@ -0,0 +1,19 @@ + + + Exe + true + false + true + true + true + true + true + + + + + + + + + diff --git a/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp b/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp new file mode 100644 index 00000000000000..813a6710217a46 --- /dev/null +++ b/src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp @@ -0,0 +1,121 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#include + +typedef void (STDMETHODCALLTYPE *CallbackFn)(); + +static CallbackFn s_callback; + +static void RunCallbackDuringThreadDestruction() +{ + printf("[native] invoking managed callback from the thread destruction callback\n"); + fflush(stdout); + + s_callback(); + + printf("[native] managed callback returned; the runtime did not detect the re-initialization\n"); + fflush(stdout); +} + +#ifdef _WIN32 + +// FLS rather than TLS: FlsAlloc takes a destruction callback, which is also the mechanism the +// runtime itself uses to learn that a thread is terminating. + +static DWORD s_flsIndex = FLS_OUT_OF_INDEXES; + +static VOID WINAPI FlsCallback(PVOID) +{ + RunCallbackDuringThreadDestruction(); +} + +static DWORD WINAPI ThreadProc(LPVOID) +{ + s_callback(); + + // Arms FlsCallback, which the OS invokes while this thread is being destroyed. + if (!FlsSetValue(s_flsIndex, reinterpret_cast(1))) + { + abort(); + } + + return 0; +} + +extern "C" DLL_EXPORT void RunCallbackOnThreadAndDuringItsDestruction(CallbackFn callback) +{ + s_callback = callback; + + s_flsIndex = FlsAlloc(FlsCallback); + if (s_flsIndex == FLS_OUT_OF_INDEXES) + { + abort(); + } + + HANDLE thread = CreateThread(nullptr, 0, ThreadProc, nullptr, 0, nullptr); + if (thread == nullptr) + { + abort(); + } + + if (WaitForSingleObject(thread, INFINITE) != WAIT_OBJECT_0) + { + abort(); + } + + CloseHandle(thread); + FlsFree(s_flsIndex); +} + +#else // _WIN32 + +#define AbortIfFail(st) if (st != 0) abort() + +static pthread_key_t s_key; + +static void KeyDestructor(void*) +{ + RunCallbackDuringThreadDestruction(); +} + +static void* ThreadProc(void*) +{ + s_callback(); + + // Arms KeyDestructor. pthread key destructors run after the C++ thread_local destructors + // that the runtime uses to tear down its per-thread state. + // The callback below observes a thread whose runtime thread state is already gone. + int st = pthread_setspecific(s_key, reinterpret_cast(1)); + AbortIfFail(st); + + return nullptr; +} + +extern "C" DLL_EXPORT void RunCallbackOnThreadAndDuringItsDestruction(CallbackFn callback) +{ + s_callback = callback; + + int st = pthread_key_create(&s_key, KeyDestructor); + AbortIfFail(st); + + pthread_attr_t attr; + st = pthread_attr_init(&attr); + AbortIfFail(st); + + // We need to set the stack size due to the very small (80kB) default stack size on MUSL + // based Linux distros. + st = pthread_attr_setstacksize(&attr, 0x180000); // 1.5MB + AbortIfFail(st); + + pthread_t thread; + st = pthread_create(&thread, &attr, ThreadProc, nullptr); + AbortIfFail(st); + + pthread_attr_destroy(&attr); + + st = pthread_join(thread, nullptr); + AbortIfFail(st); +} + +#endif // _WIN32