Uh oh!
There was an error while loading. Please reload this page.
Fix GC hole when method return is hijacked for GC suspension - #129714
Conversation
Some library test suites running in Interp-JIT mixed configuration displayed a high rate of failure. This turned out to be caused by hijacking the return of a JIT frame that was returning into the interpreter. When the return value is hijacked a new explicit HijackFrame is created, however this frame doesn't produce any roots, it just facilitates reaching the calling frame for which we scan the actual GCInfo. This frame will own and scan the value from the return register from the hijacked frame. The problem arises when the calling method is the interpreter. The interpreter calls compiled methods directly and it has no state of the registers. The first operation that the interpreter does with the return value is to store it on the interpreter stack. However, if the return address is hijacked, we don't enter the interpreter to publish the values to the interpreter stack so the values end up not being scanned at all. As a fix, we disable hijacking if the return address is not jitted code, with the same pattern used in HandleSuspensionForInterruptedThread, which should be signal safe. HijackFrame actually reports some roots from registers on X86, but adding this for all calling convention specifics on other arches seems like overkill. Looking at the nature of this failure, the question arises whether this is an interpreter only fix. After some additional investigation, it turns out that at least reflection based method invocation via `MethodBaseInvoker.InterpretedInvoke_Method` -> `RuntimeMethodHandle_InvokeMethod` -> `CallDescrWorkerInternal`. Was able to reliably reproduce this by running with `DOTNET_HeapVerify=1` with forced interpreted invoke on the following sample https://gist.github.com/BrzVlad/debbb230b75a26f31c1b8b670e6e0924. Maybe other callsites could run into the same issue.
There was a problem hiding this comment.
Pull request overview
This PR adjusts CoreCLR’s thread hijacking during GC suspension so that, on non-x86 targets, the runtime does not hijack a method return when the saved return address doesn’t map to JITted code (e.g., returning into the interpreter), avoiding a GC root hole for return-register values.
Changes:
- Add an
EECodeInfovalidity check (non-x86) inThread::HijackThreadand early-out if the hijacked return address is not JITted code. - On ARM64, strip PAC bits from the return address (via
PacStripPtr) before using it for code identity checks. - Add an
externdeclaration forPacStripPtrinthreadsuspend.cppfor ARM64 builds.
Uh oh!
There was an error while loading. Please reload this page.
jkotas
commented
Jun 22, 2026
There should be no other unmanaged callsites of managed code except CallDescrWorker. |
Tagging subscribers to this area: @agocke |
BrzVlad
commented
Jun 23, 2026
/ba-g unrelated build timeouts |
Uh oh!
There was an error while loading. Please reload this page.
Some library test suites running in Interp-JIT mixed configuration displayed a high rate of failure. This turned out to be caused by hijacking the return of a JIT frame that was returning into the interpreter. When the return value is hijacked a new explicit HijackFrame is created, however this frame doesn't produce any roots, it just facilitates reaching the calling frame for which we scan the actual GCInfo. This frame will own and scan the value from the return register from the hijacked frame. The problem arises when the calling method is the interpreter. The interpreter calls compiled methods directly and it has no state of the registers. The first operation that the interpreter does with the return value is to store it on the interpreter stack. However, if the return address is hijacked, we don't enter the interpreter to publish the values to the interpreter stack so the values end up not being scanned at all. As a fix, we disable hijacking if the return address is not jitted code, with the same pattern used in HandleSuspensionForInterruptedThread, which should be signal safe. HijackFrame actually reports some roots from registers on X86, but adding this for all calling convention specifics on other arches seems like overkill. Looking at the nature of this failure, the question arises whether this is an interpreter only fix. After some additional investigation, it turns out that at least reflection based method invocation via `MethodBaseInvoker.InterpretedInvoke_Method` -> `RuntimeMethodHandle_InvokeMethod` -> `CallDescrWorkerInternal`. Was able to reliably reproduce this by running with `DOTNET_HeapVerify=1` with forced interpreted invoke on the following sample https://gist.github.com/BrzVlad/debbb230b75a26f31c1b8b670e6e0924. Maybe other callsites could run into the same issue.
Some library test suites running in Interp-JIT mixed configuration displayed a high rate of failure. This turned out to be caused by hijacking the return of a JIT frame that was returning into the interpreter. When the return value is hijacked a new explicit HijackFrame is created, however this frame doesn't produce any roots, it just facilitates reaching the calling frame for which we scan the actual GCInfo. This frame will own and scan the value from the return register from the hijacked frame. The problem arises when the calling method is the interpreter. The interpreter calls compiled methods directly and it has no state of the registers. The first operation that the interpreter does with the return value is to store it on the interpreter stack. However, if the return address is hijacked, we don't enter the interpreter to publish the values to the interpreter stack so the values end up not being scanned at all.
As a fix, we disable hijacking if the return address is not jitted code, with the same pattern used in HandleSuspensionForInterruptedThread, which should be signal safe. HijackFrame actually reports some roots from registers on X86, but adding this for all calling convention specifics on other arches seems like overkill.
Looking at the nature of this failure, the question arises whether this is an interpreter only fix. After some additional investigation, it turns out that at least reflection based method invocation via
MethodBaseInvoker.InterpretedInvoke_Method->RuntimeMethodHandle_InvokeMethod->CallDescrWorkerInternal. Was able to reliably reproduce this by running withDOTNET_HeapVerify=1with forced interpreted invoke on the following sample https://gist.github.com/BrzVlad/debbb230b75a26f31c1b8b670e6e0924. Maybe other callsites could run into the same issue.