Repository navigation
RuntimeAsync: a byref-like struct local holding object references is stored into the continuation without a GC write barrier #136000
Description
Summary
Under runtime-async (Features=runtime-async=on), when a method suspends, the JIT copies live locals into the continuation object (a GC heap object). For a ref struct (byref-like) local whose fields hold managed object references but no byref/Span<T>, the copy is emitted as a plain SIMD/mov block without a write barrier. Because the continuation is reused across loop iterations and is promoted to gen2, the object reference stored in that slot is never recorded in the card table. When the referenced young object later moves, the slot is left dangling and the background GC faults when it walks that reference.
The design spec (docs/design/specs/runtime-async.md) states that byref-like structs are not hoisted across suspension points and have their default value afterward, so arguably they should not be in the continuation at all; but the concrete, deterministic defect is the missing write barrier on the store that does happen.
Impact
Intermittent SIGSEGV in the background GC (SVR::gc_heap::background_mark_simple1) in a long-running server process. DOTNET_HeapVerify=1 turns it into a deterministic FATAL_GC_ERROR at the "Card not set" check. The crash depends on GC timing, so it surfaces as a rare, hard-to-attribute background-GC segfault in production.
Observed with .NET interpolated string handlers (ref struct types with two List/object fields and some int fields) used in an async logging call inside a long-running loop. Reproduces with any byref-like struct of the same shape.
Versions
- Reproduced on
11.0.0-rc.1.26425.128(runtimeab19415702aa…) and11.0.0-rc.2.26504.105(runtime474ed963…), linux-x64, Server GC + concurrent GC. - The relevant JIT/VM functions are byte-identical on
mainas ofd80abfc4b04f(2026-10-17), so it is expected to be present there too.
Minimal repro
Project: net11.0, <Features>$(Features);runtime-async=on</Features>, <DebugType> default, linux-x64, Server GC. Full program:
using System.Runtime.CompilerServices;
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60));
// Drive gen0/gen1/gen2 collections so a referenced young object moves or is reclaimed.
new Thread(() =>
{
long n = 0;
while (!cts.IsCancellationRequested)
{
for (int i = 0; i < 1000; i++) _ = new byte[256];
if (++n % 10 == 0) GC.Collect(0, GCCollectionMode.Forced, true);
if (n % 50 == 0) GC.Collect(1, GCCollectionMode.Forced, true);
if (n % 500 == 0) GC.Collect(2, GCCollectionMode.Forced, false);
Thread.Sleep(1);
}
}) { IsBackground = true }.Start();
var tasks = new Task[4];
for (int i = 0; i < tasks.Length; i++) tasks[i] = Loop(cts.Token);
await Task.WhenAll(tasks);
static async Task Loop(CancellationToken ct)
{
long i = 0;
while (!ct.IsCancellationRequested)
{
Log.Info($"iter {i}"); // builds a ref-struct interpolated handler holding young objects
i++;
await Task.Delay(1).ConfigureAwait(false); // suspension point; the handler ends up in the reused continuation
}
}
static class Keep { [ThreadStatic] public static object? Slot; } // keep the young object alive so GC moves it
static class Log
{
public static long Count;
[MethodImpl(MethodImplOptions.NoInlining)]
public static void Info(ref Handler message) { if (message.Ok) Interlocked.Increment(ref Count); }
}
// Mimics a logging library's interpolated string handler: a byref-like struct with object references and no Span/byref.
[InterpolatedStringHandler]
ref struct Handler
{
object _a;
object _b;
int _literalLen, _formatted, _written;
public bool Ok;
public Handler(int literalLength, int formattedCount, out bool enabled)
{
_a = Mk();
_b = Mk();
_literalLen = literalLength; _formatted = formattedCount; _written = 0;
Ok = enabled = true;
}
static object Mk() { var o = new object[1]; Keep.Slot = o; return o; }
public void AppendLiteral(string s) => _literalLen += s.Length;
public void AppendFormatted<T>(T value) => _written++;
}Run on the RC1 or RC2 runtime with Server GC. It segfaults in background_mark_simple1 within ~10 s. Changing the single token ref struct Handler to struct Handler makes it stop (runs clean indefinitely).
Observed (linux-x64, Server GC, 4 parallel loops):
| handler type | RC1 | RC2 |
|---|---|---|
ref struct |
SIGSEGV ~10 s | SIGSEGV ~10 s |
struct |
clean | clean |
With DOTNET_HeapVerify=1 the ref-struct build can also abort at SVR::gc_heap::verify_heap diagnostics.cpp:1482 ("(in loop) Card not set") with the faulting slot pointing at a young object[] from a previous iteration; that check is timing-sensitive and does not fire on every run, whereas the plain SIGSEGV is reliable.
Note on shape: the interpolated-string-handler codegen is what keeps the handler live into the continuation. A plain new Handler(...) local that is used and then dead before the await is treated as dead and not stored, so it does not reproduce. The $"..." call-site lowering (construct, conditional Append*, pass by ref) is the shape that hits this, which is why real interpolated-handler logging libraries trigger it.
Root cause (reading the JIT)
-
AsyncAnalysis::IsLive(jit/asyncanalysis.cpp) excludes from the continuation only byref locals and structs that contain a byref (dsc->GetLayout()->HasGCByRef()). A byref-like struct that holds object references but no byref is not excluded, and an address-exposed/untracked local reports live, so it is saved. -
AsyncTransformation::FillInDataOnSuspension(jit/async.cpp) stores that struct local into the continuation withgtNewStoreValueNode(dsc->GetLayout(), continuation+offset, value, GTF_IND_NONFAULTING). The destination address is the continuation, a heap object. (For the byref/implicit-byref path the same function setsGTF_IND_TGT_NOT_HEAP, but for this struct path it does not set any target-heap-ness flag.) -
In lowering,
LowerBlockStore/LowerCopyBlockStore(jit/lower.cpp) computesisNotHeap = blkNode->IsAddressNotOnHeap(), andGenTreeIndir::IsAddressNotOnHeap(jit/gentree.cpp) returns true whenGetLayout()->IsStackOnly().ClassLayout::IsStackOnly(jit/layout.cpp) returns true for any byref-like (eeIsByrefLike) layout. So withdoCpObj && isNotHeap, the barrier is dropped and the copy is unrolled (vmovdqu).
The IsStackOnly heuristic encodes "a byref-like value lives on the stack," which is true for an ordinary ref-struct-to-ref-struct copy. The async transform breaks the premise: it copies a byref-like-shaped payload into a heap object, so the destination is not stack even though the layout is byref-like.
Codegen diff (same method, same field layout)
ref struct local — no barrier:
G_M000_IG22:
vmovdqu ymm0, ymmword ptr [rbp+0x68]
vmovdqu ymmword ptr [r14+0x40], ymm0 ; r14 = continuation (heap), +0x40 = two object refs
same layout declared as a non-ref struct — barrier:
lea rdi, bword ptr [r14+0x40]
lea rsi, [rbp+0x68]
mov edx, 32
call [CORINFO_HELP_BULK_WRITEBARRIER]
This is on the reused-continuation path; the newly-allocated-continuation path (gen0) would be safe either way, but the reused continuation is promoted to gen2 in a loop.
Suggested fix directions (for maintainers to choose)
- Do not treat a byref-like layout as stack-only for this store: the store target is the continuation. Either mark the continuation stores as heap-targeted so lowering keeps the barrier, or make
IsAddressNotOnHeap/IsStackOnlyreason about the destination address rather than the value's type here. - Or honor the spec and do not hoist byref-like struct locals into the continuation at all (extend the
IsLiveexclusion from "contains a byref" to "is byref-like"), giving them their documented default value after suspension.
Repro
Single-file, self-contained repro (the program above, plus a csproj): https://gist.github.com/libu1129/bf1508a8858fbc9e943e010e86786920
Build with the .NET 11 RC1 or RC2 SDK and run on linux-x64 with Server GC. ref struct Handler segfaults within ~10 s; changing the one token to struct Handler runs clean.
Activity
dotnet-policy-service commentedon Oct 17, 2026
Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
See info in area-owners.md if you want to be subscribed.
JIT: Exclude byref-like structs from async continuation capture (#136085
EgorBo commentedon Oct 19, 2026
Thanks! Fixed by #136085 in main, I'll backport the fix to .NET 11.0 (although, it might miss the GA)