Uh oh!
There was an error while loading. Please reload this page.
JIT: Clear out return value references from continuations - #129157
Conversation
Async1 deterministically clears out awaiters on resumption, meaning that the callee's `Task` and its result do not stay alive. This change similarly makes it so that we do not keep results alive in async2. Async1 has similar clearing for locals based on lexical scope. I am hoping we can get away with not implementing something similar for runtime async (it would be expensive and impossible to guarantee similar behavior as async1).
There was a problem hiding this comment.
Pull request overview
This PR updates the CoreCLR JIT’s runtime-async transformation to proactively clear GC references from the continuation’s stored return value after resumption copies the result out, reducing unintended object rooting when continuations are reused.
Changes:
- Adds
AsyncTransformation::ClearReturnValueOnResumptionand invokes it after copying the awaited call’s return value when continuation reuse is enabled. - Implements two clearing strategies for struct returns with GC pointers: per-GC-slot clearing for small/ref-sparse structs, otherwise a bulk zeroing store.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
src/coreclr/jit/async.h | Declares the new helper used to clear return-value GC references on resumption. |
src/coreclr/jit/async.cpp | Calls the helper after copying results and implements logic to clear GC refs in ref/struct return slots when continuations are reused. |
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
jakobbotsch
commented
Jun 9, 2026
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Validated locally that this fixes #126735 after the following part of the test is extracted into a For reference, this is the diff applied to the test: diff --git a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpConnectionManagerTests.cs b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpConnectionManagerTests.cs
index da38bd2c5b..1638f258f3 100644
--- a/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpConnectionManagerTests.cs+++ b/src/Servers/Kestrel/test/InMemory.FunctionalTests/HttpConnectionManagerTests.cs@@ -3,6 +3,7 @@
using System;
using System.Diagnostics;
+using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Server.Kestrel.Core.Internal.Infrastructure;
@@ -56,14 +57,10 @@ public class HttpConnectionManagerTests : LoggedTest
},
testContext))
{
- using (var connection = server.CreateConnection())- {- await connection.SendEmptyGet();-- Assert.True(await appStartedWh.WaitAsync(TestConstants.DefaultTimeout));-- // Close connection without waiting for a response- }+ // Create the connection in a separate non-inlined method so that the connection+ // and any locals it references aren't kept rooted on this method's stack frame,+ // which would prevent HttpConnection from being garbage collected.+ await CreateConnectionAndSendRequest(server, appStartedWh);
var logWaitAttempts = 0;
@@ -77,6 +74,19 @@ public class HttpConnectionManagerTests : LoggedTest
}
}
+ [MethodImpl(MethodImplOptions.NoInlining)]+ private static async Task CreateConnectionAndSendRequest(TestServer server, SemaphoreSlim appStartedWh)+ {+ using (var connection = server.CreateConnection())+ {+ await connection.SendEmptyGet();++ Assert.True(await appStartedWh.WaitAsync(TestConstants.DefaultTimeout));++ // Close connection without waiting for a response+ }+ }+
private class CallbackLoggerProvider : ILoggerProvider
{
private readonly Action<EventId> _logAction; |
Uh oh!
There was an error while loading. Please reload this page.
) Async1 deterministically clears out awaiters on resumption, meaning that the callee's `Task` and its result do not stay alive. This change similarly makes it so that we do not keep results alive in async2. Async1 has similar clearing for locals based on lexical scope. I am hoping we can get away with not implementing something similar for runtime async (it would be expensive and impossible to guarantee similar behavior as async1).
See dotnet/runtime#129157 for some context. Co-authored-by: William Godbe <wigodbe@microsoft.com>
Async1 deterministically clears out awaiters on resumption, meaning that the callee's `Task` and its result do not stay alive. This change similarly makes it so that we do not keep results alive in async2. Async1 has similar clearing for locals based on lexical scope. I am hoping we can get away with not implementing something similar for runtime async (it would be expensive and impossible to guarantee similar behavior as async1).
Async1 deterministically clears out awaiters on resumption, meaning that the callee's
Taskand its result do not stay alive. This change similarly makes it so that we do not keep results alive in async2.Async1 has similar clearing for locals based on lexical scope. I am hoping we can get away with not implementing something similar for runtime async (it would be expensive and impossible to guarantee similar behavior as async1).
Fix#126735