Skip to content

Abort when there's an attempt to reinitialize a runtime thread object - #132448

Merged
eduardo-vp merged 24 commits into
dotnet:mainfrom
eduardo-vp:abort-when-reinitializing-thread
Sep 17, 2026
Merged

eduardo-vp merged 24 commits into
dotnet:mainfrom
eduardo-vp:abort-when-reinitializing-thread

Conversation

@eduardo-vp

@eduardo-vp eduardo-vp commented Aug 18, 2026 •

Copy link
Copy Markdown
Member

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 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.

@azure-pipelines

Copy link
Copy Markdown
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.

@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 Thread on 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

  • Main reads s_callbackCount and s_secondCallbackGotNewThread after the native thread completes, but there is no managed synchronization edge between the foreign thread’s writes and these reads. Use Volatile.Read for 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;
        }

Comment thread src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.csproj Outdated
Comment thread src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs Outdated
Copilot AI review requested due to automatic review settings August 18, 2026 20:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 STDMETHODCALLTYPE so the signature is consistent and the Windows x86 build doesn't accidentally pick up a cdecl definition when the #ifdef conditions change or get refactored.
extern "C" DLL_EXPORT void RunCallbackOnThreadAndDuringItsDestruction(CallbackFn callback)

src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyedNative.cpp:73

  • AbortIfFail is a macro with an unparenthesized parameter and no do { } while (0) wrapper. As written it’s easy to misuse in an if/else and can also evaluate expressions with surprising precedence. Wrap it and parenthesize the argument to make it safe.
#define AbortIfFail(st) if (st != 0) abort()

@eduardo-vp

Copy link
Copy Markdown
Member Author

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.

Copilot AI review requested due to automatic review settings August 19, 2026 17:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();
}

Copilot AI review requested due to automatic review settings August 20, 2026 00:35

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 declares CallbackFn with 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 where STDMETHODCALLTYPE is 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. Add STDMETHODCALLTYPE to 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.ProcessPath can be null; other tests in this repo guard it before using it as ProcessStartInfo.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();

Copilot AI review requested due to automatic review settings August 20, 2026 22:13

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, ThreadStateKeyDestructor calls pthread_setspecific to 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 to PTHREAD_DESTRUCTOR_ITERATIONS) for every terminating thread. That means this adds extra destructor callbacks and pthread_setspecific calls on every managed-thread teardown. Consider using a marker that doesn’t require re-setting the key from its own destructor (e.g., a PLATFORM_THREAD_LOCAL flag set in TlsDestructionMonitor), or otherwise structuring this so it doesn’t force repeated destructor iterations.
static void ThreadStateKeyDestructor(void* state)
{
    if (state == &g_threadStateDestroyedMarker)
    {
        SetThreadStateDestroyed();

Copilot AI review requested due to automatic review settings August 20, 2026 22:28

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

  • CheckThreadStateNotDestroyed is documented as “fails fast”, but in _DEBUG builds the current _ASSERTE_ALL_BUILDS(!"...") expands to _ASSERTE, which does not reliably terminate the process. In non-_DEBUG VM builds it routes through EEPOLICY_HANDLE_FATAL_ERROR(COR_E_EXECUTIONENGINE) and drops the assertion text, so the new test’s message check may fail on release legs. Consider using EEPOLICY_HANDLE_FATAL_ERROR_WITH_MESSAGE for 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 to Winapi (stdcall), but the native export is declared without an explicit calling convention (so it will be __cdecl by default). This can cause stack imbalance or even failure to resolve the entry point on 32-bit Windows. Specify CallingConvention.Cdecl (or update the native export to STDMETHODCALLTYPE and keep the default) so managed and native agree across architectures.
    [DllImport(NativeLib)]
    private static extern void RunCallbackOnThreadAndDuringItsDestruction(delegate* unmanaged<void> callback);

Copilot AI review requested due to automatic review settings September 16, 2026 08:37

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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, but CheckThreadStateNotDestroyed reaches _ASSERTE_ALL_BUILDS, which uses EEPOLICY_HANDLE_FATAL_ERROR(COR_E_EXECUTIONENGINE) in the VM and logs only the generic Internal CLR error text, 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

Copilot AI review requested due to automatic review settings September 16, 2026 12:43

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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 (CheckThreadStateNotDestroyed lines 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

Comment thread src/coreclr/vm/ceemain.cpp Outdated
Copilot AI review requested due to automatic review settings September 16, 2026 18:50

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 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_activated is 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 hit CheckThreadStateNotDestroyed and abort incorrectly. Move the marker assignment into the if (m_activated) block alongside RuntimeThreadShutdown.
#ifdef TARGET_APPLE
        SetThreadStateDestroyed();
#else
        t_threadStateDestroyed = true;
#endif

src/tests/baseservices/threading/ThreadStateDestroyed/ThreadStateDestroyed.cs:102

  • Although exitCode is captured here, it is only logged. The Windows Release path later skips ExpectedMessage, 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 returning Pass.
        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 FlsCallback after CoreCLR has already allocated its own FLS slot (InitFlsSlot runs while the finalizer thread starts). Windows invokes FLS destructors in reverse allocation order, so this callback can run before FiberDetachCallback; in that order the second managed callback executes while the runtime Thread is 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 later FlsAlloc slot.
    s_flsIndex = FlsAlloc(FlsCallback);
  • Files reviewed: 7/7 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@eduardo-vp

Copy link
Copy Markdown
Member Author

The test now disables the OS core dump generation and it's working fine with _ASSERTE_ALL_BUILDS, passing on all legs. Failures seem unrelated.

Comment thread src/coreclr/vm/ceemain.cpp Outdated
Comment thread src/coreclr/vm/threads.cpp
Copilot AI review requested due to automatic review settings September 17, 2026 01:15

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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

  • exitCode is only printed, so in Windows Release (where ExpectedMessage is 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 returning Pass, 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: CheckThreadStateNotDestroyed uses _ASSERTE_ALL_BUILDS, whose release VM definition expands to EEPOLICY_HANDLE_FATAL_ERROR(COR_E_EXECUTIONENGINE) without the assertion text, and HandleFatalError prints 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 !IsReleaseRuntime for 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_callback observes the still-attached runtime Thread, 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

Comment thread src/coreclr/vm/threads.cpp

@jkotas jkotas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks

@eduardo-vp
eduardo-vp merged commit 2d8267c into dotnet:main Sep 17, 2026
117 checks passed
@dotnet-milestone-bot dotnet-milestone-bot Bot added this to the 12.0-preview1 milestone Sep 18, 2026
jtschuster pushed a commit to jtschuster/runtime that referenced this pull request Sep 18, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Detect an attempt to re-initialize the runtime thread object and abort immediately

3 participants