Abort when there's an attempt to reinitialize a runtime thread object - #132448
Conversation
|
Azure Pipelines: Successfully started running 4 pipeline(s). 12 pipeline(s) were filtered out due to trigger conditions. There may be pipelines that require an authorized user to comment /azp run to run. |
|
Tagging subscribers to this area: @JulieLeeMSFT, @VSadov |
There was a problem hiding this comment.
Pull request overview
Adds a new CoreCLR baseservices test that attempts to execute managed code from a thread-destruction callback after the runtime has already torn down per-thread state, expecting the runtime to fail fast rather than re-attaching a new managed Thread object.
Changes:
- Introduces a new test project (
ThreadStateDestroyed) configured for process isolation and crash-based pass criteria (expected exit codes). - Adds a native test library that invokes a managed callback both during normal thread execution and again from a thread-destruction callback (Windows FLS / Unix pthread key destructor).
- Adds managed test logic that tracks whether the runtime re-attaches a new
Threadon the second callback (should be unreachable if fail-fast works).
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp | Native helper that runs a managed callback on a thread and again during thread teardown (FLS/pthread destructor). |
| src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.csproj | New SDK-style test project with isolation/crash expectations and native CMake project reference. |
| src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs | Managed entry point and UnmanagedCallersOnly callback used to detect (unexpected) thread re-attachment. |
| src/tests/baseservices/threading/ThreadStateDestroyed/CMakeLists.txt | Builds the native shared library used by the test. |
Suppressed comments (1)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:57
Mainreadss_callbackCountands_secondCallbackGotNewThreadafter the native thread completes, but there is no managed synchronization edge between the foreign thread’s writes and these reads. UseVolatile.Readfor the shared fields (and keep using the local copies for messaging) so the result is reliable under weak memory models.
// Only reachable when the runtime did not fail fast.
if (s_callbackCount != 2)
{
Console.WriteLine($"[managed] Expected exactly 2 callbacks but got {s_callbackCount}.");
return 102;
}
if (!s_secondCallbackGotNewThread)
{
Console.WriteLine("[managed] The second callback reused the existing Thread.");
return 103;
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.
Suppressed comments (2)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:95
- Same calling-convention issue as the Windows implementation: the non-Windows definition should also include
STDMETHODCALLTYPEso the signature is consistent and the Windows x86 build doesn't accidentally pick up a cdecl definition when the#ifdefconditions change or get refactored.
extern "C" DLL_EXPORT void RunCallbackOnThreadAndDuringItsDestruction(CallbackFn callback)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:73
AbortIfFailis a macro with an unparenthesized parameter and nodo { } while (0)wrapper. As written it’s easy to misuse in anif/elseand can also evaluate expressions with surprising precedence. Wrap it and parenthesize the argument to make it safe.
#define AbortIfFail(st) if (st != 0) abort()
|
The results of the test are as expected: on linux it fails but EBR fires first and on macOS we can see the actual bug because it doesn't detect re-initialization. After the fix the test should pass on all legs. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:80
- On POSIX, pthread key destructors may be invoked repeatedly (up to PTHREAD_DESTRUCTOR_ITERATIONS) if the key’s value remains non-null after the destructor runs. Since this destructor doesn’t clear the key, a non-failfast runtime could end up invoking the managed callback multiple times during teardown, adding noise and making failures harder to interpret. Clearing the key value here makes the behavior deterministic.
static void KeyDestructor(void*)
{
RunCallbackDuringThreadDestruction();
}
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (4)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:6
- On Windows x86,
delegate* unmanaged<void>uses the platform default (Winapi/stdcall). This file declaresCallbackFnwith the compiler default calling convention (cdecl), so calling the managed callback can corrupt the stack on 32-bit Windows. Match the callback type to Winapi/stdcall (and keep it a no-op on Unix whereSTDMETHODCALLTYPEis empty).
typedef void (*CallbackFn)();
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:46
- The exported entrypoint is invoked via
[DllImport]without an explicit calling convention, so it uses Winapi (stdcall on Windows x86). This export currently has the compiler default calling convention (cdecl), which can corrupt the stack on 32-bit Windows. AddSTDMETHODCALLTYPEto match the P/Invoke default (no effect on Unix).
extern "C" DLL_EXPORT void RunCallbackOnThreadAndDuringItsDestruction(CallbackFn callback)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:77
Environment.ProcessPathcan be null; other tests in this repo guard it before using it asProcessStartInfo.FileName. Guarding avoids an unrelated NullReference/ArgumentNull failure mode and makes the test failure clearer if it ever happens.
ProcessStartInfo psi = new ProcessStartInfo(Environment.ProcessPath, arguments)
src/coreclr/vm/ceemain.h:51
- The comment says this helper "fails fast" unconditionally, but the implementation intentionally becomes a no-op in free builds once any IJW module has been loaded (it uses
_ASSERTE, which is compiled out). Update the comment to reflect that this is conditionally enforced.
// Fails fast if the runtime thread state of the current thread has already been destroyed.
void CheckThreadStateNotDestroyed();
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/coreclr/vm/ceemain.cpp:1813
- On Unix,
ThreadStateKeyDestructorcallspthread_setspecificto restore the key value. Per POSIX, setting a non-NULL value from a key destructor causes the destructor to be invoked again in subsequent destructor-iteration passes (up toPTHREAD_DESTRUCTOR_ITERATIONS) for every terminating thread. That means this adds extra destructor callbacks andpthread_setspecificcalls on every managed-thread teardown. Consider using a marker that doesn’t require re-setting the key from its own destructor (e.g., aPLATFORM_THREAD_LOCALflag set inTlsDestructionMonitor), or otherwise structuring this so it doesn’t force repeated destructor iterations.
static void ThreadStateKeyDestructor(void* state)
{
if (state == &g_threadStateDestroyedMarker)
{
SetThreadStateDestroyed();
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 7 out of 7 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/coreclr/vm/ceemain.cpp:1894
CheckThreadStateNotDestroyedis documented as “fails fast”, but in_DEBUGbuilds the current_ASSERTE_ALL_BUILDS(!"...")expands to_ASSERTE, which does not reliably terminate the process. In non-_DEBUGVM builds it routes throughEEPOLICY_HANDLE_FATAL_ERROR(COR_E_EXECUTIONENGINE)and drops the assertion text, so the new test’s message check may fail on release legs. Consider usingEEPOLICY_HANDLE_FATAL_ERROR_WITH_MESSAGEfor the non-IJW case to guarantee fail-fast behavior and a stable diagnostic string across configurations.
if (Module::HasAnyIJWBeenLoaded())
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:32
- On Windows x86,
[DllImport]defaults toWinapi(stdcall), but the native export is declared without an explicit calling convention (so it will be__cdeclby default). This can cause stack imbalance or even failure to resolve the entry point on 32-bit Windows. SpecifyCallingConvention.Cdecl(or update the native export toSTDMETHODCALLTYPEand keep the default) so managed and native agree across architectures.
[DllImport(NativeLib)]
private static extern void RunCallbackOnThreadAndDuringItsDestruction(delegate* unmanaged<void> callback);
There was a problem hiding this comment.
🔵 Needs a closer look
Two moderate test-validation issues remain unresolved.
Review details
Suppressed comments (2)
Previously missed (1) — in code that hasn't changed since the last review.
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:117
- In Unix Release builds this condition still requires
ExpectedMessage, butCheckThreadStateNotDestroyedreaches_ASSERTE_ALL_BUILDS, which usesEEPOLICY_HANDLE_FATAL_ERROR(COR_E_EXECUTIONENGINE)in the VM and logs only the genericInternal CLR errortext, not the assertion string. The test therefore reports failure even when the intended fail-fast occurred; key the message check only on!IsReleaseRuntime(as the Windows exception already does).
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:99
- The subprocess exit code is only printed and never validated. In the Windows Release path the message check is intentionally skipped, so any unrelated crash after the native destruction-callback marker (for example, an abort or access violation before the second managed marker) is reported as a passing test. Validate the platform/configuration-specific fail-fast exit status, not just the output markers.
int exitCode = subprocess.ExitStatus.ExitCode;
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
There was a problem hiding this comment.
🟡 Changes recommended
The regression test needs stronger failure validation, and the helper comment should document its compatibility exception.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (1)
src/coreclr/vm/ceemain.h:51
- This declaration says the helper always fails fast, but the implementation intentionally suppresses the assertion in release builds when mixed-mode/IJW modules have been loaded (
CheckThreadStateNotDestroyedlines 1886-1895). Please make the comment describe the check and its compatibility exception, or otherwise document that the fail-fast behavior is conditional, so callers are not given a stronger guarantee than the implementation provides.
// Fails fast if the runtime thread state of the current thread has already been destroyed.
void CheckThreadStateNotDestroyed();
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🔵 Needs a closer look
Three moderate review findings remain unresolved in runtime teardown handling and regression-test validation.
Review details
Suppressed comments (3)
src/coreclr/vm/ceemain.cpp:1864
- The marker is set even when
m_activatedis false. The existing guard is what prevents a thread-local monitor that was never activated for a runtime thread from being treated as a destroyed runtime thread; after this destructor runs, the next first managed callback on such a thread will hitCheckThreadStateNotDestroyedand abort incorrectly. Move the marker assignment into theif (m_activated)block alongsideRuntimeThreadShutdown.
#ifdef TARGET_APPLE
SetThreadStateDestroyed();
#else
t_threadStateDestroyed = true;
#endif
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:102
- Although
exitCodeis captured here, it is only logged. The Windows Release path later skipsExpectedMessage, so any child that reaches the native destruction marker and then terminates for an unrelated reason (for example, a crash before the second callback marker) is reported as a passing test. Validate the child exit status against the expected termination for each supported platform/configuration, or require a release-specific diagnostic before returningPass.
int exitCode = subprocess.ExitStatus.ExitCode;
Console.WriteLine($"Subprocess exited with {exitCode}:");
Console.WriteLine(output);
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:50
- This Windows test registers
FlsCallbackafter CoreCLR has already allocated its own FLS slot (InitFlsSlotruns while the finalizer thread starts). Windows invokes FLS destructors in reverse allocation order, so this callback can run beforeFiberDetachCallback; in that order the second managed callback executes while the runtimeThreadis still present and the test reports a failure even though no reinitialization was attempted. Arrange for the test callback to be invoked by a teardown mechanism that is guaranteed to run after CoreCLR's FLS callback, rather than relying on this laterFlsAllocslot.
s_flsIndex = FlsAlloc(FlsCallback);
- Files reviewed: 7/7 changed files
- Comments generated: 0 new
- Review effort level: Lite
|
The test now disables the OS core dump generation and it's working fine with |
There was a problem hiding this comment.
🟡 Changes recommended
Unresolved critical and moderate findings affect attach-path coverage and regression-test reliability.
Get a fresh assessment by requesting another Copilot review.
Review details
Suppressed comments (3)
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:102
exitCodeis only printed, so in Windows Release (whereExpectedMessageis intentionally not required) any unrelated crash that occurs after the native destruction-callback marker can satisfy all checks and make this regression test pass. Validate the platform/configuration-specific fail-fast exit status (or another release-only diagnostic) before returningPass, so the test proves the new guard—not merely that the child terminated.
int exitCode = subprocess.ExitStatus.ExitCode;
Console.WriteLine($"Subprocess exited with {exitCode}:");
Console.WriteLine(output);
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:117
- On Unix Release builds, this check cannot succeed:
CheckThreadStateNotDestroyeduses_ASSERTE_ALL_BUILDS, whose release VM definition expands toEEPOLICY_HANDLE_FATAL_ERROR(COR_E_EXECUTIONENGINE)without the assertion text, andHandleFatalErrorprints the generic HRESULT message. Because this condition exempts only Windows Release, the test fails on Release Unix after the intended abort; gate the diagnostic check on!IsReleaseRuntimefor every platform (or use a release-safe marker).
if ((!OperatingSystem.IsWindows() || !TestLibrary.CoreClrConfigurationDetection.IsReleaseRuntime) && !output.Contains(ExpectedMessage))
src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:38
- This Windows test assumes that the new FLS callback runs after the runtime's FLS callback, but Windows does not document an invocation order between FLS slots. If this callback runs first,
s_callbackobserves the still-attached runtimeThread, prints callback #2, and the controller fails even though the destroyed-state check was never exercised. Use an order-independent way to trigger the callback after runtime cleanup or redesign the Windows scenario instead of relying on FLS callback order.
// Arms FlsCallback, which the OS invokes while this thread is being destroyed.
if (!FlsSetValue(s_flsIndex, reinterpret_cast<PVOID>(1)))
- Files reviewed: 7/7 changed files
- Comments generated: 1
- Review effort level: Lite
…dotnet#132448) Use a `PLATFORM_THREAD_LOCAL` variable to mark a thread which state has been destroyed and abort if we detect an attempt to reinitialize a runtime thread object. On apple's platforms we use a pthread key since `PLATFORM_THREAD_LOCAL` doesn't work correctly there. Darwin's [implementation](https://github.com/apple-oss-distributions/dyld/blob/e9da5ae571b4191dcdbcfd827363f19847c84561/libdyld/ThreadLocalVariables.h) uses pthread keys for its thread local variables and once a pthread is destroyed/freed, we totally lose the "destroyed thread" flag. Other unixes work well because its thread local mechanism doesn't rely on pthread keys, they mostly use the FS segment and some offset to find the thread local variables. Closes dotnet#112131. --------- Co-authored-by: Eduardo Velarde <evelardepola@microsoft.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Use a
PLATFORM_THREAD_LOCALvariable to mark a thread which state has been destroyed and abort if we detect an attempt to reinitialize a runtime thread object.On apple's platforms we use a pthread key since
PLATFORM_THREAD_LOCALdoesn't work correctly there. Darwin's implementation uses pthread keys for its thread local variables and once a pthread is destroyed/freed, we totally lose the "destroyed thread" flag. Other unixes work well because its thread local mechanism doesn't rely on pthread keys, they mostly use the FS segment and some offset to find the thread local variables.Closes #112131.