Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Improve GCHeap::Promote debug validation - #127595

Merged
jkotas merged 8 commits into
mainfrom
issue-127581
Apr 30, 2026
Merged

Improve GCHeap::Promote debug validation#127595
jkotas merged 8 commits into
mainfrom
issue-127581

Conversation

@jkotas

@jkotasjkotas commented Apr 30, 2026

Copy link
Copy Markdown
Member

Fixes#127581

CopilotAI review requested due to automatic review settings April 30, 2026 04:51
@jkotasjkotas changed the title Import GCHeap::Promote debug validationImprove GCHeap::Promote debug validationApr 30, 2026
@dotnet-policy-service

Copy link
Copy Markdown
Contributor

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

CopilotAI 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

This PR aims to strengthen debug-time validation in GCHeap::Promote by validating candidate object pointers even on the early-return path when the pointer is outside the find_object bookkeeping range.

Changes:

  • Add a _DEBUG-only CObjectHeader::Validate() call when !gc_heap::is_in_find_object_range(o) and the root is not an interior pointer.

Comment threadsrc/coreclr/gc/interface.cpp
@github-actions

This comment has been minimized.

@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

CopilotAI review requested due to automatic review settings April 30, 2026 05:08
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:08

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Change the priority for test119403.cs to 0 temporarily to make the test exercised in the CI

Done in bfa4fd1. Changed CLRTestPriority from 1 to 0 in test119403.csproj.

@jkotas

Copy link
Copy Markdown
MemberAuthor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

…ackground_promote_callback
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/39187d33-4e52-43e2-93c3-11e2d254f48d
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 05:13
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 05:13

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

gc_heap::background_promote (background.cpp:547) and gc_heap::background_promote_callback

@copilot Address feeback

Done in b05e42d. Applied the same fix to both background_promote and background_promote_callback: moved the DEBUG_DestroyedHandleValue check before is_in_find_object_range, and added Validate() in the early-return path (guarded by _DEBUG and !GC_CALL_INTERIOR).

Comment threadsrc/coreclr/gc/background.cpp Outdated
Comment threadsrc/coreclr/gc/background.cpp Outdated
@jkotas

Copy link
Copy Markdown
MemberAuthor

@copilot Implement the fix mentioned in #127581 (comment)

…mes (x86 port of gcinfodecoder.cpp fix)
Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/af5d831b-114e-4bc0-8290-96c0186ccf47
Co-authored-by: jkotas <6668460+jkotas@users.noreply.github.com>
CopilotAI review requested due to automatic review settings April 30, 2026 12:03
@jkotas
jkotas removed the request for review from CopilotApril 30, 2026 12:03

CopilotAI commented Apr 30, 2026

Copy link
Copy Markdown
Contributor

@copilot Implement the fix mentioned in #127581 (comment)

Done in f66c09c. Added the equivalent of the gcinfodecoder.cpp fix to EnumGcRefsX86 in gc_unwind_x86.inl: when a partially-interruptible method is aborted (exception in flight) and the current offset is not within an interruptible range, skip all GC reporting. Untracked locals may not be initialized at the faulting offset, and since execution won't resume there, there's no need to report them.

Comment threadsrc/coreclr/vm/gc_unwind_x86.inl Outdated
CopilotAI review requested due to automatic review settings April 30, 2026 13:19

CopilotAI 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

This PR addresses a class of x86 GC hole issues (tracked in #127581) by aligning x86 GC reference enumeration behavior with the earlier fix from #119403 and by strengthening debug-time validation in GCHeap::Promote paths so invalid/out-of-range object references fail deterministically in debug builds.

Changes:

  • Update x86 GCInfo/stack-walk enumeration to skip reporting entirely for execution-aborted, non-interruptible methods (where slots may not be initialized).
  • Add additional _DEBUG validation when Promote/background promotion sees a non-null, non-interior pointer that is outside the GC “find_object” bookkeeping range.
  • Adjust the regression test project priority to make the scenario easier to exercise in standard test runs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated no comments.

FileDescription
src/tests/Regressions/coreclr/GitHub_119403/test119403.csprojChanges test priority to run as a default-priority regression.
src/coreclr/vm/gc_unwind_x86.inlSkips GC reporting for execution-aborted non-interruptible methods on x86 to avoid reporting uninitialized GC slots.
src/coreclr/gc/interface.cppAdds debug validation in GCHeap::Promote when an out-of-range non-interior reference is encountered.
src/coreclr/gc/background.cppAdds the same debug validation behavior to background GC promotion paths.

@github-actions

This comment has been minimized.

@mangod9

mangod9 commented Apr 30, 2026

Copy link
Copy Markdown
Member

The fix is working:

CI Validation of the Fix

Build 1403154 — ❌ Failed (without x86 fix)

  • Commit: bc54dbf (Apr 30 05:32 UTC)
  • Contains: Validate change in interface.cpp + test promoted to Pri0 — but NOT the gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → failed
  • Failure: Regressions workitem in Helix job ab237a9f-5703-40e6-9caa-e9eba9d566d0 Assert: !"Detected use of a
    corrupted OBJECTREF. Possible GC hole."
    Object::Validate + 0xA9
    → WKS::GCHeap::Promote + 0x169 ← new Validate() catches the bad ref
    → GcEnumObject
    → EnumGcRefsX86 ← reports untracked slot for aborted frame
    → SubProblem + 5 ← fault offset (before local 's' initialized)

Build 1403719 — ✅ Passed (with x86 fix)

  • Commit: 9a9b245 → includes f66c09c "Fix EnumGcRefsX86 to skip reporting for non-interruptible aborted frames" (Apr 30 12:03 UTC)
  • Contains: Validate change + test Pri0 + gc_unwind_x86.inl fix
  • Result: coreclr Pri0 Runtime Tests Run windows x86 checked → succeeded (44m 3s)
  • EnumGcRefsX86 now returns early for the aborted non-interruptible frame, so Promote/Validate is never called with the uninitialized slot

Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Comment threadsrc/coreclr/gc/interface.cpp Outdated
Comment threadsrc/tests/Regressions/coreclr/GitHub_119403/test119403.csproj Outdated
Co-authored-by: Jan Kotas <jkotas@microsoft.com>
CopilotAI review requested due to automatic review settings April 30, 2026 16:39
@jkotas
jkotas enabled auto-merge (squash) April 30, 2026 16:40
@github-actions

Copy link
Copy Markdown
Contributor

🤖 Copilot Code Review — PR #127595

Note

This review was generated by GitHub Copilot.

Holistic Assessment

Motivation: The PR addresses a real correctness issue where EnumGcRefsX86 could report uninitialized GC slots for non-interruptible aborted frames, and improves debug validation in GC promote functions. These are genuine bugs that can cause GC heap corruption or crashes.

Approach: The changes are minimal and well-targeted — reordering the DEBUG_DestroyedHandleValue check before is_in_find_object_range (race condition correctness), adding debug-only Validate() calls for early diagnostics, and porting the existing gcinfodecoder.cpp logic to the x86 EnumGcRefsX86 path. The approach mirrors existing patterns already present in the codebase.

Summary: ✅ LGTM. The changes are correct, minimal, and consistent with established codebase patterns. All three GC promote functions (GCHeap::Promote, background_promote, background_promote_callback) are now consistent. The x86 fix is a straightforward port of logic already proven in gcinfodecoder.cpp (lines 808-812).


Detailed Findings

✅ Correctness — DEBUG_DestroyedHandleValue reordering (interface.cpp, background.cpp)

Moving the DEBUG_DestroyedHandleValue check before is_in_find_object_range is correct. During concurrent scanning, a handle can be destroyed (set to the sentinel value) while the GC is scanning. The destroyed sentinel value is not a valid heap address, so is_in_find_object_range would return false, but without the reordering, the new Validate() call (added in the !is_in_find_object_range path) could attempt to validate the sentinel value as an object, which would crash. Checking the sentinel first avoids this.

✅ Correctness — Debug validation in early-return path

Adding ((CObjectHeader*)o)->Validate() under _DEBUG for non-null, non-interior objects that fall outside the GC range is a sound diagnostic addition. The guards (o != NULL and !(flags & GC_CALL_INTERIOR)) are appropriate — null pointers obviously cannot be validated, and interior pointers do not point to object headers.

✅ Correctness — EnumGcRefsX86 aborted frame fix (gc_unwind_x86.inl)

The added condition ((flags & ExecutionAborted) && !info.interruptible) exactly matches the logic in gcinfodecoder.cpp at lines 808-812 (which skips reporting when countIntersections == 0 && executionAborted — meaning the frame is not fully interruptible). For non-interruptible methods where execution is aborted, GC slots may not be initialized at the current offset, so reporting them could cause the GC to trace garbage values. The existing _ASSERTE(flags & ExecutionAborted) at line 3103 already documents that this early-return path is only expected for aborted frames — the new condition simply widens the gate to include the non-interruptible aborted case alongside prolog/epilog.

✅ Test Priority — test119403.csproj

Changing priority from 0 to 1 follows the convention: "New JIT regression tests should typically be CLRTestPriority 1." The test was temporarily set to 0 earlier in the PR branch for verification, then correctly restored to 1 for merge.

✅ Consistency — All three promote functions aligned

GCHeap::Promote, background_promote, and background_promote_callback now all follow the same pattern: check DEBUG_DestroyedHandleValue → check is_in_find_object_range (with debug validation) → proceed. This eliminates an inconsistency that existed before this PR.

Generated by Code Review for issue #127595 ·

CopilotAI 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

This PR targets a win-x86 GC root reporting hole by (1) aligning the x86 GCInfo unwinder behavior with the earlier fix in #119403 and (2) strengthening debug-time GC promotion validation so this class of corruption becomes easier to reproduce and diagnose deterministically.

Changes:

  • Update x86 GC root enumeration to skip reporting for non-interruptible methods when execution has been aborted (in addition to prolog/epilog cases).
  • Add _DEBUG-only validation in GCHeap::Promote and background promotion paths when an object reference falls outside the find_object bookkeeping range.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.

FileDescription
src/coreclr/vm/gc_unwind_x86.inlExtends the “skip reporting” condition to cover aborted + non-interruptible methods, matching the intended semantics from the earlier fix.
src/coreclr/gc/interface.cppMoves destroyed-handle sentinel check earlier and adds debug validation when the ref is outside is_in_find_object_range.
src/coreclr/gc/background.cppAdds similar debug validation in background promote paths (including callback).

Comment threadsrc/coreclr/gc/interface.cpp
Comment threadsrc/coreclr/gc/background.cpp
Comment threadsrc/coreclr/gc/background.cpp
@mangod9

Copy link
Copy Markdown
Member

Btw, there is a libraries x86 failure but doesnt look like that is related to this change. The auto analysis is below. @VSadov surprised that ThreadAbort is being throw here?

!m_RedirectContextInUse Assert on win-x86 — Analysis

Assert

Assert failure (Thread 0x590): !m_RedirectContextInUse
File: src/coreclr/vm/threads.h:3104
Image: dotnet.exe (x86 Checked)
Exit code: -1073740286 (STATUS_FAIL_FAST_EXCEPTION)

Fires in Thread::MarkRedirectContextInUse when the runtime attempts to redirect (hijack) a thread whose redirect context is already occupied from a prior redirect.

Thread State at Crash

ThreadOSIDGC ModeRoleActivity
110x590CooperativeCrash threadInside 1st redirect → HandleThreadAbort → constructing ThreadAbortException → 2nd redirect fires assert
120xcccCooperativeGC threadRunning GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggered GC
60x101cPreemptiveTiered compilationJIT compiling, blocked in WaitUntilGCComplete
50x1910PreemptiveFinalizerWaiting for finalizer event
00x1ad8PreemptiveMainxunit runner waiting

Full Stack — Thread 11 (OSID 0x590) — Native + Managed

Shows two nested redirects — the second one fires the assert:

=== 2nd redirect (fires the assert) ===
coreclr!FailFastOnAssert
coreclr!_DbgBreakCheck
coreclr!_DbgBreakCheckNoThrow
coreclr!DbgAssertDialog
coreclr!Thread::MarkRedirectContextInUse ← 2nd redirect: context already in use!
coreclr!Thread::RedirectedHandledJITCase ← 2nd redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 2nd GC hijack entry point
=== Managed code running inside 1st redirect (ThreadAbortException construction) ===
System.IO.BinaryReader.ReadByte()
System.IO.BinaryReader.Read7BitEncodedInt()
System.Resources.ResourceReader.LoadString(Int32)
System.Resources.RuntimeResourceSet.ReadValue(...)
System.Resources.RuntimeResourceSet.GetObject(...)
System.Resources.RuntimeResourceSet.GetString(...)
System.Resources.ResourceManager.GetString(...)
System.SR.InternalGetResourceString(String)
System.SR.GetResourceString(String)
System.SR.get_Arg_SystemException()
System.SystemException..ctor()
System.Threading.ThreadAbortException..ctor()
System.Runtime.CompilerServices.RuntimeHelpers.CallDefaultConstructor(...)
=== 1st redirect processing the pending ThreadAbort ===
coreclr!CallDefaultConstructor
coreclr!EEException::CreateThrowable
coreclr!CLRException::GetThrowable
coreclr!CLRException::GetThrowableFromException
coreclr!Thread::HandleThreadAbort ← processes pending TS_AbortRequested
coreclr!Thread::RestoreContextSimulated
coreclr!Thread::RedirectedHandledJITCase ← 1st redirect handler
coreclr!Thread::RedirectedHandledJITCaseForGCThreadControl
coreclr!RedirectedHandledJITCaseForGCThreadControl_Stub ← 1st GC hijack entry point
=== Original managed code when 1st redirect hijacked the thread ===
[InlinedCallFrame] System.RuntimeMethodHandle.InvokeMethod(...)
System.Reflection.MethodBaseInvoker.InterpretedInvoke_Method(Object, IntPtr*)
System.Reflection.RuntimeMethodInfo.Invoke(...)
System.Reflection.MethodBase.Invoke(Object, Object[])
Xunit.Sdk.TestInvoker`1.CallTestMethod(Object)
Xunit.Sdk.TestInvoker`1+<>c__DisplayClass47_0.<<InvokeTestMethodAsync>b__1>d.MoveNext()
... (xunit async test invocation pipeline) ...
Xunit.Sdk.XunitTestCase.RunAsync(IMessageSink, IMessageBus, Object[], ExceptionAggregator, CancellationTokenSource)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase.<>n__0(...)
Microsoft.DotNet.XUnitExtensions.SkippedFactTestCase+<RunAsync>d__2.MoveNext()
... (xunit async test runner pipeline) ...
Xunit.Sdk.XunitTestAssemblyRunner+<RunTestCollectionAsync>d__18.MoveNext()
System.Threading.ExecutionContext.RunInternal(...)
... (async continuations) ...
System.Threading.Tasks.Task+CancellationPromise`1[[Boolean]].ITaskCompletionAction.Invoke(Task)
System.Threading.Tasks.CompletionActionInvoker.Execute()
System.Threading.ThreadPoolWorkQueue.Dispatch()
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

GC Thread Stack (Thread 12, OSID 0xccc)

System.GC.GetTotalAllocatedBytesPrecise() ← triggers GC
System.GC.GetTotalAllocatedBytes(Boolean)
System.Tests.GCExtendedTests.GetTotalAllocatedBytes() ← the test
... (xunit async pipeline via reflection invoke) ...
System.Threading.PortableThreadPool+WorkerThread.WorkerDoWork(PortableThreadPool)
System.Threading.PortableThreadPool+WorkerThread.WorkerThreadStart()
System.Threading.Thread.StartCallback(Thread*)

Sequence of Events

  1. Thread 12 runs GCExtendedTests.GetTotalAllocatedBytes()GC.GetTotalAllocatedBytesPrecise() → triggers a GC collection
  2. GC suspension begins. Thread 11 is in Cooperative mode running a test via SkippedFactTestCase.RunAsync. The runtime redirects (hijacks) Thread 11 via RedirectedHandledJITCaseForGCThreadControl_Stub (1st redirect)
  3. Inside RedirectedHandledJITCaseRestoreContextSimulated, the runtime calls Thread::HandleThreadAbort — a pending thread abort (TS_AbortRequested) was set on Thread 11. The source of the abort is not captured in the dump. On modern .NET (6+), Thread.Abort() throws PlatformNotSupportedException; thread aborts can only be initiated by the runtime internally or via ControlledExecution.Run
  4. HandleThreadAbortCreateThrowableCallDefaultConstructor for ThreadAbortException — this runs managed code to construct the exception (loading the resource string "Arg_SystemException" via SR.GetResourceStringResourceReader.LoadStringBinaryReader.ReadByte)
  5. The managed code runs in Cooperative mode, making Thread 11 eligible for GC hijacking again
  6. A second GC triggers (or GC suspension retries). The runtime attempts to redirect Thread 11 again via RedirectedHandledJITCaseForGCThreadControl_Stub (2nd redirect)
  7. MarkRedirectContextInUse finds m_RedirectContextInUse == true (still held from step 2) → assert fires

Key Finding

There is no separate cancellation thread visible in the dump. The ThreadAbort was a pending flag (TS_AbortRequested) already set on Thread 11. The source of the abort is not captured in the dump — on modern .NET, thread aborts can come from ControlledExecution.Run or other runtime-internal mechanisms. Thread::HandleThreadAbort processes this flag as part of the 1st redirect handler's normal flow (RestoreContextSimulatedHandleThreadAbort). The bug is that the 1st redirect handler runs managed code (exception construction) while still holding the redirect context, creating a window where a 2nd redirect can be attempted.

Root Cause

A double-redirect race condition on x86: the runtime hijacks a thread for GC suspension, processes a pending ThreadAbort inside the redirect handler, and the ThreadAbortException constructor runs managed code (resource string loading) in Cooperative mode. This makes the thread eligible for another GC redirect while the first redirect context is still held.

Why This Hasn't Been Seen Before

  • Libraries Test Run checked coreclr windows x86 Debug only runs on PR builds, not main rolling builds
  • Main rolling builds run Libraries Test Run checked coreclr windows x86 **Release** — Release libraries are faster, narrowing the timing window
  • Debug libraries' slower execution (resource loading, extra checks) widens the race window between the first redirect and the second attempt
  • The m_RedirectContextInUse assert only fires in Checked builds

Relation to PR #127595

Not related. The gc_unwind_x86.inl fix changes GC reporting logic inside EnumGcRefsX86. The Validate() change in interface.cpp adds a Debug assert in Promote. Neither affects thread redirect/hijack logic. This assert passed on the previous build of the same PR (build 1403154), confirming it's intermittent.

@jkotas
jkotas merged commit 0fa45b3 into mainApr 30, 2026
110 checks passed
@jkotas
jkotas deleted the issue-127581 branch April 30, 2026 19:06
@github-actionsgithub-actionsBot locked and limited conversation to collaborators May 31, 2026
Sign up for freeto subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GC hole in Regressions/coreclr/GitHub_119403/test119403/test119403.dll on win-x86

4 participants

@jkotas@mangod9