From 4d3f709efbb7141c7ae80f98a3842318cd9c8a33 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sat, 16 May 2026 15:16:18 -0700 Subject: [PATCH 01/14] Support async esxpessions --- .../Ast/AsyncBodyExpression.cs | 183 ++++++++++++++++++ .../Microsoft.Dynamic/Ast/AwaitExpression.cs | 56 ++++++ .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 70 +++++++ 3 files changed, 309 insertions(+) create mode 100644 src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs create mode 100644 src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs create mode 100644 src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs new file mode 100644 index 00000000..52e60d1e --- /dev/null +++ b/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs @@ -0,0 +1,183 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +using Microsoft.Scripting.Runtime; +using Microsoft.Scripting.Utils; + +namespace Microsoft.Scripting.Ast { + /// + /// Wraps an async function body (possibly containing + /// nodes) into an expression that evaluates to of + /// . + /// + /// + /// Unlike , this is a + /// sub-expression: it does not introduce a new lambda scope, so the body has + /// direct access to parameters and locals of the enclosing scope. Callers + /// typically wrap the resulting Task in their own coroutine façade type. + /// + /// The body is expected to evaluate to an (can be null): + /// that value becomes the Task's result. + ///
+ /// State-machine splitting at await sites is delegated to ; + /// the runtime-async await opcodes come from + /// , which Roslyn compiles into a runtime-async + /// method when the project sets <Features>runtime-async=on</Features> + /// on .NET 11+. + ///
+ public sealed class AsyncBodyExpression : Expression { + private Expression? _reduced; + + internal AsyncBodyExpression(string? name, Expression body) { + Name = name; + Body = body; + } + + /// Optional diagnostic name (forwarded to the inner generator). + public string? Name { get; } + + /// The function body. May contain nodes. + public Expression Body { get; } + + public override bool CanReduce => true; + + public override Type Type => typeof(Task); + + public override ExpressionType NodeType => ExpressionType.Extension; + + public override Expression Reduce() { + // Cache the reduction so that LabelTarget identity is preserved + // across the multiple Reduce() invocations the compiler may make + // (closure analysis, IL emission, etc.). Without this the + // inner GeneratorRewriter sees yields whose Target was minted on + // a different Reduce() call than the surrounding generator. + return _reduced ??= BuildReduction(); + } + + private Expression BuildReduction() { + // resultSlot: where the runner writes each awaited result before resuming. + // exceptionSlot: where the runner stashes a faulted-await exception so the + // state machine can rethrow at the resumed position (lets + // a Python try/except around `await` observe e.g. + // StopAsyncIteration). + // returnSlot: where the body's final value lands before the runner returns. + ParameterExpression resultSlot = Expression.Variable(typeof(StrongBox), "$awaitResult"); + ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); + ParameterExpression returnSlot = Expression.Variable(typeof(StrongBox), "$asyncReturn"); + LabelTarget yieldLabel = Expression.Label(typeof(object), "$asyncYield"); + + // Rewrite AwaitExpression(e) -> { yield e; rethrow-if-pending; resultSlot.Value } + var rewriter = new AwaitToYieldRewriter(yieldLabel, resultSlot, exceptionSlot); + Expression rewrittenBody = rewriter.Visit(Body); + + // Capture whatever the body evaluates to into returnSlot. + Expression captureReturn; + if (Body.Type == typeof(void)) { + captureReturn = rewrittenBody; + } else { + Expression asObject = rewrittenBody.Type == typeof(object) + ? rewrittenBody + : Expression.Convert(rewrittenBody, typeof(object)); + captureReturn = Expression.Assign( + Expression.Field(returnSlot, nameof(StrongBox.Value)), + asObject); + } + Expression generatorBody = Expression.Block(typeof(void), captureReturn); + + Expression generator = Utils.Generator( + Name ?? "$async", + yieldLabel, + generatorBody, + typeof(IEnumerator), + rewriteAssignments: false); + + Expression drive = Expression.Call( + typeof(AsyncRunner).GetMethod(nameof(AsyncRunner.Drive))!, + generator, + resultSlot, + returnSlot, + exceptionSlot); + + return Expression.Block( + typeof(Task), + new[] { resultSlot, exceptionSlot, returnSlot }, + Expression.Assign(resultSlot, Expression.New(typeof(StrongBox))), + Expression.Assign(exceptionSlot, Expression.New(typeof(StrongBox))), + Expression.Assign(returnSlot, Expression.New(typeof(StrongBox))), + drive); + } + + protected override Expression VisitChildren(ExpressionVisitor visitor) { + Expression b = visitor.Visit(Body); + if (b == Body) return this; + return new AsyncBodyExpression(Name, b); + } + + private sealed class AwaitToYieldRewriter : ExpressionVisitor { + private static readonly MethodInfo s_captureMethod + = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Capture))!; + private static readonly MethodInfo s_throwMethod + = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Throw), Type.EmptyTypes)!; + + private readonly LabelTarget _yieldLabel; + private readonly ParameterExpression _resultSlot; + private readonly ParameterExpression _exceptionSlot; + + public AwaitToYieldRewriter(LabelTarget yieldLabel, ParameterExpression resultSlot, ParameterExpression exceptionSlot) { + _yieldLabel = yieldLabel; + _resultSlot = resultSlot; + _exceptionSlot = exceptionSlot; + } + + protected override Expression VisitExtension(Expression node) { + if (node is AwaitExpression aw) { + Expression operand = Visit(aw.Operand); + Expression boxed = operand.Type == typeof(object) ? operand : Expression.Convert(operand, typeof(object)); + Expression readException = Expression.Field(_exceptionSlot, nameof(StrongBox.Value)); + Expression readSlot = Expression.Field(_resultSlot, nameof(StrongBox.Value)); + + // After the yield, if the runner stored an exception, rethrow it + // preserving the original stack trace (so the body's try/except + // observes the right exception object). Otherwise return the + // awaited result. + Expression rethrow = Expression.IfThen( + Expression.ReferenceNotEqual(readException, Expression.Constant(null, typeof(Exception))), + Expression.Call( + Expression.Call(s_captureMethod, readException), + s_throwMethod)); + + return Expression.Block( + typeof(object), + Utils.YieldReturn(_yieldLabel, boxed), + rethrow, + readSlot); + } + return base.VisitExtension(node); + } + } + } + + public partial class Utils { + /// + /// Wraps an async-function body in an . + /// The body may contain suspension points + /// and should evaluate to ; the resulting expression + /// evaluates to Task<object>. + /// + public static AsyncBodyExpression AsyncBody(string? name, Expression body) { + ContractUtils.RequiresNotNull(body, nameof(body)); + return new AsyncBodyExpression(name, body); + } + } +} diff --git a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs new file mode 100644 index 00000000..c542984b --- /dev/null +++ b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Linq.Expressions; + +using Microsoft.Scripting.Utils; + +namespace Microsoft.Scripting.Ast { + /// + /// A suspension point inside an . The + /// is expected to evaluate to a + /// or . + /// + /// + /// + /// The drives + /// the lambda by calling its GeneratorNext form; each await yields the + /// task to the runner, which performs a real runtime-async await and + /// resumes the body with the boxed result. + ///
+ /// Standalone reduction is not supported - + /// rewrites these nodes into yield+resume pairs before the body is lowered. + ///
+ public sealed class AwaitExpression : Expression { + internal AwaitExpression(Expression operand) { + Operand = operand; + } + + /// The awaitable being awaited. Must produce a Task or Task<T>. + public Expression Operand { get; } + + public override bool CanReduce => false; + + public override Type Type => typeof(object); + + public override ExpressionType NodeType => ExpressionType.Extension; + + protected override Expression VisitChildren(ExpressionVisitor visitor) { + Expression o = visitor.Visit(Operand); + if (o == Operand) return this; + return new AwaitExpression(o); + } + } + + public partial class Utils { + /// Wraps in an . + public static AwaitExpression Await(Expression awaitable) { + ContractUtils.RequiresNotNull(awaitable, nameof(awaitable)); + return new AwaitExpression(awaitable); + } + } +} diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs new file mode 100644 index 00000000..8f66bc08 --- /dev/null +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -0,0 +1,70 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Microsoft.Scripting.Runtime { + /// + /// Runtime-async orchestrator for . + /// + /// + /// The method is itself an async Task whose IL is + /// produced by Roslyn on .NET 11 under <Features>runtime-async=on</Features>, + /// so each await below becomes a real .NET 11 runtime-async opcode. The state + /// machine of the Python function body is delegated to a + /// GeneratorRewriter-produced of yielded + /// tasks; this method awaits each one and feeds the result back through the + /// shared result slot. Faulted awaits are routed through the exception slot + /// so the body's try/except can observe them at the resumption point — this + /// is how async for's StopAsyncIteration catch works. + /// + public static class AsyncRunner { + public static async Task Drive(IEnumerator states, StrongBox resultSlot, StrongBox returnSlot, StrongBox exceptionSlot) { + while (states.MoveNext()) { + object yielded = states.Current; + if (yielded is Task task) { + try { + await task.ConfigureAwait(false); + resultSlot.Value = ExtractTaskResult(task); + exceptionSlot.Value = null; + } catch (Exception ex) { + // The body's rewriter inserts a check after each yield + // that rethrows this on the resumed thread, so a Python + // try/except inside `await` can catch it. + resultSlot.Value = null; + exceptionSlot.Value = ex; + } + } else { + // Synchronously-produced value forwards straight through. + resultSlot.Value = yielded; + exceptionSlot.Value = null; + } + } + return returnSlot.Value; + } + + private static object? ExtractTaskResult(Task task) { + // The runtime type may be a Task subclass (like AsyncStateMachineBox); + // walk up until we find Task. + Type? t = task.GetType(); + while (t is not null) { + if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Task<>)) { + Type arg = t.GenericTypeArguments[0]; + // Roslyn-emitted async Task methods carry an internal + // VoidTaskResult type argument; surface that as null. + if (!arg.IsVisible) return null; + return t.GetProperty("Result")!.GetValue(task); + } + t = t.BaseType; + } + return null; + } + } +} From b140f3b233b148f8abef83cb1a12d1aaaeb9b2d9 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sat, 16 May 2026 16:15:01 -0700 Subject: [PATCH 02/14] Optimize ExtractTaskResult --- .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 20 ++++++------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index 8f66bc08..80ecae6d 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -51,20 +51,12 @@ public static class AsyncRunner { } private static object? ExtractTaskResult(Task task) { - // The runtime type may be a Task subclass (like AsyncStateMachineBox); - // walk up until we find Task. - Type? t = task.GetType(); - while (t is not null) { - if (t.IsGenericType && t.GetGenericTypeDefinition() == typeof(Task<>)) { - Type arg = t.GenericTypeArguments[0]; - // Roslyn-emitted async Task methods carry an internal - // VoidTaskResult type argument; surface that as null. - if (!arg.IsVisible) return null; - return t.GetProperty("Result")!.GetValue(task); - } - t = t.BaseType; - } - return null; + // The runtime type may be a Task subclass (e.g. AsyncStateMachineBox, or RuntimeAsyncTask); + // so find Task.Result through hierarchy + var prop = task.GetType().GetProperty("Result"); // this may be incorrect in the unlikely (and bad) case if the subclass shadows Result (.e.g new T2 Result {...}, not in BCL/CLR) + if (prop is null) return null; // non-generic Task + if (!prop.PropertyType.IsVisible) return null; // Roslyn-emitted or CLR async Task uses an internal VoidTaskResult type argument; surface that as null. + return prop.GetValue(task); // Task.Result, may be null (and is null if task has not completed yet) } } } From 1a10f9fe6012fd77bf0359c069c3056ce0c7ae4b Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sun, 17 May 2026 12:36:44 -0700 Subject: [PATCH 03/14] Await on caller's sync context --- src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index 80ecae6d..2dd3feaf 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -31,13 +31,10 @@ public static class AsyncRunner { object yielded = states.Current; if (yielded is Task task) { try { - await task.ConfigureAwait(false); + await task; // honor caller's SyncContext / TaskScheduler resultSlot.Value = ExtractTaskResult(task); exceptionSlot.Value = null; } catch (Exception ex) { - // The body's rewriter inserts a check after each yield - // that rethrows this on the resumed thread, so a Python - // try/except inside `await` can catch it. resultSlot.Value = null; exceptionSlot.Value = ex; } From 6500aa2f12dc8534aeda09b1da7629a7e4d79c73 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Mon, 18 May 2026 15:58:07 -0700 Subject: [PATCH 04/14] Add cancellation token to async expressions --- .../Ast/AsyncBodyExpression.cs | 44 +++++++++++-- .../Microsoft.Dynamic.csproj | 2 + .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 61 +++++++++++++++---- 3 files changed, 88 insertions(+), 19 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs index 52e60d1e..e477af22 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs @@ -10,6 +10,7 @@ using System.Reflection; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; +using System.Threading; using System.Threading.Tasks; using Microsoft.Scripting.Runtime; @@ -39,9 +40,10 @@ namespace Microsoft.Scripting.Ast { public sealed class AsyncBodyExpression : Expression { private Expression? _reduced; - internal AsyncBodyExpression(string? name, Expression body) { + internal AsyncBodyExpression(string? name, Expression body, Expression cancellationToken) { Name = name; Body = body; + CancellationToken = cancellationToken; } /// Optional diagnostic name (forwarded to the inner generator). @@ -50,6 +52,13 @@ internal AsyncBodyExpression(string? name, Expression body) { /// The function body. May contain nodes. public Expression Body { get; } + /// + /// Expression evaluating to a + /// that samples between iterations and links + /// to each suspended task. Defaults to default(CancellationToken). + /// + public Expression CancellationToken { get; } + public override bool CanReduce => true; public override Type Type => typeof(Task); @@ -107,7 +116,8 @@ private Expression BuildReduction() { generator, resultSlot, returnSlot, - exceptionSlot); + exceptionSlot, + CancellationToken); return Expression.Block( typeof(Task), @@ -120,8 +130,9 @@ private Expression BuildReduction() { protected override Expression VisitChildren(ExpressionVisitor visitor) { Expression b = visitor.Visit(Body); - if (b == Body) return this; - return new AsyncBodyExpression(Name, b); + Expression ct = visitor.Visit(CancellationToken); + if (b == Body && ct == CancellationToken) return this; + return new AsyncBodyExpression(Name, b, ct); } private sealed class AwaitToYieldRewriter : ExpressionVisitor { @@ -173,11 +184,32 @@ public partial class Utils { /// Wraps an async-function body in an . /// The body may contain suspension points /// and should evaluate to ; the resulting expression - /// evaluates to Task<object>. + /// evaluates to Task<object>. Cancellation defaults to + /// default(CancellationToken); use the + /// overload to + /// supply one. /// public static AsyncBodyExpression AsyncBody(string? name, Expression body) { ContractUtils.RequiresNotNull(body, nameof(body)); - return new AsyncBodyExpression(name, body); + return new AsyncBodyExpression(name, body, Expression.Default(typeof(CancellationToken))); + } + + /// + /// Wraps an async-function body in an + /// with a caller-provided . + /// The token expression is evaluated once when the body starts and is + /// then sampled by between iterations + /// and at each suspended await. + /// + public static AsyncBodyExpression AsyncBody(string? name, Expression body, Expression cancellationToken) { + ContractUtils.RequiresNotNull(body, nameof(body)); + ContractUtils.RequiresNotNull(cancellationToken, nameof(cancellationToken)); + if (cancellationToken.Type != typeof(CancellationToken)) { + throw new ArgumentException( + $"Expression must evaluate to {nameof(CancellationToken)}, got {cancellationToken.Type}.", + nameof(cancellationToken)); + } + return new AsyncBodyExpression(name, body, cancellationToken); } } } diff --git a/src/core/Microsoft.Dynamic/Microsoft.Dynamic.csproj b/src/core/Microsoft.Dynamic/Microsoft.Dynamic.csproj index 7ad7b3e5..7acb50d9 100644 --- a/src/core/Microsoft.Dynamic/Microsoft.Dynamic.csproj +++ b/src/core/Microsoft.Dynamic/Microsoft.Dynamic.csproj @@ -14,6 +14,8 @@ M:System.Reflection.MethodInfo.CreateDelegate``1; M:System.String.IndexOf(System.Char,System.StringComparison); M:System.String.StartsWith(System.Char); + M:System.Threading.Tasks.Task.WaitAsync(System.Threading.CancellationToken); + M:System.Threading.Tasks.Task`1.WaitAsync(System.Threading.CancellationToken); diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index 2dd3feaf..9d3d277a 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -8,33 +8,68 @@ using System.Collections.Generic; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; +using System.Threading; using System.Threading.Tasks; namespace Microsoft.Scripting.Runtime { /// /// Runtime-async orchestrator for . /// - /// - /// The method is itself an async Task whose IL is - /// produced by Roslyn on .NET 11 under <Features>runtime-async=on</Features>, - /// so each await below becomes a real .NET 11 runtime-async opcode. The state - /// machine of the Python function body is delegated to a - /// GeneratorRewriter-produced of yielded - /// tasks; this method awaits each one and feeds the result back through the - /// shared result slot. Faulted awaits are routed through the exception slot - /// so the body's try/except can observe them at the resumption point — this - /// is how async for's StopAsyncIteration catch works. - /// public static class AsyncRunner { - public static async Task Drive(IEnumerator states, StrongBox resultSlot, StrongBox returnSlot, StrongBox exceptionSlot) { + /// + /// Drives a language function body, awaiting each yielded + /// and feeding the result back through . + /// Faulted awaits are routed through so + /// the body's try/except can observe them at the resumption point — this + /// is how async for's StopAsyncIteration catch works. + /// + /// + /// This method is itself an async Task whose IL is produced by + /// Roslyn on .NET 11 under <Features>runtime-async=on</Features>, + /// so each await below becomes a real .NET 11 runtime-async opcode. + /// The state machine of the language function body is delegated to a + /// GeneratorRewriter-produced of yielded + /// tasks. + /// + /// Continuation scheduling is determined by the caller's + /// / — the + /// awaits here do not call ConfigureAwait(false), so a host that + /// installs a single-threaded context (e.g. an asyncio-style event loop) + /// pins every resumption to its loop thread. + /// + /// Cancellation is cooperative. + /// is sampled at each loop iteration (so a stretch of synchronously-resolved + /// awaits is still cancellable) and is linked to each suspended task so a + /// cancellation request unblocks an in-flight await even when the awaited + /// task does not honor the token itself. If cancellation fires during an + /// await, the resulting is + /// routed through exactly like any other + /// awaited fault, so a body-level try/except around the await can + /// observe it; otherwise it bubbles out and the returned Task transitions + /// to because the OCE's token matches. + /// + public static async Task Drive( + IEnumerator states, + StrongBox resultSlot, + StrongBox returnSlot, + StrongBox exceptionSlot, + CancellationToken cancellationToken = default) { while (states.MoveNext()) { + // Sample between iterations: catches cancellation requested during a stretch of synchronously-resolved yields, + // and is the (uncatchable) interruption point Python try/except cannot guard. + cancellationToken.ThrowIfCancellationRequested(); + object yielded = states.Current; if (yielded is Task task) { try { - await task; // honor caller's SyncContext / TaskScheduler + // Task.WaitAsync is BCL on net6+, polyfilled by Meziantou.Polyfill on net4x/netstandard2.0. + // No ConfigureAwait(false): honor caller's SyncContext / TaskScheduler. + await task.WaitAsync(cancellationToken); resultSlot.Value = ExtractTaskResult(task); exceptionSlot.Value = null; } catch (Exception ex) { + // The body's rewriter inserts a check after each yield that rethrows this on the resumed thread, + // so a try/except inside `await` can catch it (including the cancellation OCE from WaitAsync). resultSlot.Value = null; exceptionSlot.Value = ex; } From 88aa76d73f204419aa9d8308e9732b2fa8d6c15b Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Mon, 18 May 2026 20:39:14 -0700 Subject: [PATCH 05/14] Reduce number of slots used by AsyncRunner.Drive --- .../Ast/AsyncBodyExpression.cs | 91 ++++++++++--------- .../Microsoft.Dynamic/Ast/AwaitExpression.cs | 21 ++--- .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 90 ++++++++++-------- 3 files changed, 112 insertions(+), 90 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs index e477af22..e0cb76c6 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs @@ -18,24 +18,24 @@ namespace Microsoft.Scripting.Ast { /// - /// Wraps an async function body (possibly containing - /// nodes) into an expression that evaluates to of - /// . + /// Wraps an async function body (possibly containing + /// nodes) into an expression that evaluates to of + /// . /// /// - /// Unlike , this is a - /// sub-expression: it does not introduce a new lambda scope, so the body has - /// direct access to parameters and locals of the enclosing scope. Callers - /// typically wrap the resulting Task in their own coroutine façade type. - /// - /// The body is expected to evaluate to an (can be null): - /// that value becomes the Task's result. - ///
- /// State-machine splitting at await sites is delegated to ; - /// the runtime-async await opcodes come from - /// , which Roslyn compiles into a runtime-async - /// method when the project sets <Features>runtime-async=on</Features> - /// on .NET 11+. + /// Unlike , this is a + /// sub-expression: it does not introduce a new lambda scope, so the body has + /// direct access to parameters and locals of the enclosing scope. Callers + /// typically wrap the resulting Task in their own coroutine façade type. + ///
+ /// The body is expected to evaluate to an (can be null): + /// that value becomes the Task's result. + ///
+ /// State-machine splitting at await sites is delegated to ; + /// the runtime-async await opcodes come from + /// , which Roslyn compiles into a runtime-async + /// method when the project sets <Features>runtime-async=on</Features> + /// on .NET 11+. ///
public sealed class AsyncBodyExpression : Expression { private Expression? _reduced; @@ -66,43 +66,50 @@ internal AsyncBodyExpression(string? name, Expression body, Expression cancellat public override ExpressionType NodeType => ExpressionType.Extension; public override Expression Reduce() { - // Cache the reduction so that LabelTarget identity is preserved - // across the multiple Reduce() invocations the compiler may make - // (closure analysis, IL emission, etc.). Without this the - // inner GeneratorRewriter sees yields whose Target was minted on - // a different Reduce() call than the surrounding generator. + // Cache the reduction so that LabelTarget identity is preserved across the multiple Reduce() invocations the compiler may make + // (closure analysis, IL emission, etc.). Without this the inner GeneratorRewriter sees yields whose Target was minted on a + // different Reduce() call than the surrounding generator. return _reduced ??= BuildReduction(); } private Expression BuildReduction() { - // resultSlot: where the runner writes each awaited result before resuming. - // exceptionSlot: where the runner stashes a faulted-await exception so the - // state machine can rethrow at the resumed position (lets - // a Python try/except around `await` observe e.g. - // StopAsyncIteration). - // returnSlot: where the body's final value lands before the runner returns. - ParameterExpression resultSlot = Expression.Variable(typeof(StrongBox), "$awaitResult"); + // valueSlot is value cell shared with AsyncRunner.Drive. + // - At each await: the runner writes the awaited result here just before resuming the body, and the body reads it via the + // `readSlot` expression the rewriter inserts after each yield. + // - At the end of the body: the body's final return value is written here (see captureFinalValue below). After MoveNext() + // returns false, Drive reads the same slot and returns it as the Task's result. + // + // The two uses do not overlap — by the time captureFinalValue runs, the last per-await read has already been consumed or + // discarded by the surrounding expression. + ParameterExpression valueSlot = Expression.Variable(typeof(StrongBox), "$asyncValue"); + + // exceptionSlot is where the runner stashes a faulted-await exception so the state machine can rethrow at the resumed position + // (lets a Python try/except around `await` observe e.g. StopAsyncIteration). ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); - ParameterExpression returnSlot = Expression.Variable(typeof(StrongBox), "$asyncReturn"); LabelTarget yieldLabel = Expression.Label(typeof(object), "$asyncYield"); - // Rewrite AwaitExpression(e) -> { yield e; rethrow-if-pending; resultSlot.Value } - var rewriter = new AwaitToYieldRewriter(yieldLabel, resultSlot, exceptionSlot); + // Rewrite AwaitExpression(e) -> { yield e; rethrow-if-pending; valueSlot.Value } + var rewriter = new AwaitToYieldRewriter(yieldLabel, valueSlot, exceptionSlot); Expression rewrittenBody = rewriter.Visit(Body); - // Capture whatever the body evaluates to into returnSlot. - Expression captureReturn; + // After the body completes, the function's final value must live in valueSlot for Drive to pick up. For a value-typed body this + // is a single assignment of the body expression into the slot. For a void body, the body has no value to assign, but we still + // must clear the slot — otherwise Drive would return whatever the last await happened to stash there. (IronPython doesn't emit + // void async bodies today, but AsyncBodyExpression is language-agnostic.) + Expression valueField = Expression.Field(valueSlot, nameof(StrongBox.Value)); + Expression captureFinalValue; if (Body.Type == typeof(void)) { - captureReturn = rewrittenBody; + captureFinalValue = Expression.Block( + typeof(void), + rewrittenBody, + Expression.Assign(valueField, Expression.Constant(null, typeof(object)))); } else { Expression asObject = rewrittenBody.Type == typeof(object) ? rewrittenBody : Expression.Convert(rewrittenBody, typeof(object)); - captureReturn = Expression.Assign( - Expression.Field(returnSlot, nameof(StrongBox.Value)), - asObject); + captureFinalValue = Expression.Assign(valueField, asObject); } - Expression generatorBody = Expression.Block(typeof(void), captureReturn); + Expression generatorBody = Expression.Block(typeof(void), captureFinalValue); Expression generator = Utils.Generator( Name ?? "$async", @@ -114,17 +121,15 @@ private Expression BuildReduction() { Expression drive = Expression.Call( typeof(AsyncRunner).GetMethod(nameof(AsyncRunner.Drive))!, generator, - resultSlot, - returnSlot, + valueSlot, exceptionSlot, CancellationToken); return Expression.Block( typeof(Task), - new[] { resultSlot, exceptionSlot, returnSlot }, - Expression.Assign(resultSlot, Expression.New(typeof(StrongBox))), + [ valueSlot, exceptionSlot ], + Expression.Assign(valueSlot, Expression.New(typeof(StrongBox))), Expression.Assign(exceptionSlot, Expression.New(typeof(StrongBox))), - Expression.Assign(returnSlot, Expression.New(typeof(StrongBox))), drive); } diff --git a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs index c542984b..35f526f4 100644 --- a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs @@ -11,19 +11,18 @@ namespace Microsoft.Scripting.Ast { /// - /// A suspension point inside an . The - /// is expected to evaluate to a - /// or . + /// A suspension point inside an . The + /// is expected to evaluate to a + /// or . /// - /// /// - /// The drives - /// the lambda by calling its GeneratorNext form; each await yields the - /// task to the runner, which performs a real runtime-async await and - /// resumes the body with the boxed result. - ///
- /// Standalone reduction is not supported - - /// rewrites these nodes into yield+resume pairs before the body is lowered. + /// The drives + /// the lambda by calling its GeneratorNext form; each await yields the + /// task to the runner, which performs a real runtime-async await and + /// resumes the body with the boxed result. + ///
+ /// Standalone reduction is not supported - + /// rewrites these nodes into yield+resume pairs before the body is lowered. ///
public sealed class AwaitExpression : Expression { internal AwaitExpression(Expression operand) { diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index 9d3d277a..6a60f272 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -17,71 +17,89 @@ namespace Microsoft.Scripting.Runtime { /// public static class AsyncRunner { /// - /// Drives a language function body, awaiting each yielded - /// and feeding the result back through . - /// Faulted awaits are routed through so - /// the body's try/except can observe them at the resumption point — this - /// is how async for's StopAsyncIteration catch works. + /// Drives a language function body, awaiting each yielded and feeding the result back through . Faulted awaits are routed through so the body's try/except can observe + /// them at the resumption point — this is how async for's StopAsyncIteration catch works. /// + /// The body's state machine — an enumerator of yielded awaitables. + /// + /// While the body is running, this is where the runner writes each awaited result before resuming the body (read by the body at + /// the resume point of each await). After the body completes, this is where the body has stored its final return value, + /// which becomes the returned Task's result. The two uses do not overlap: the body has consumed (or discarded) any per-await + /// value before its final assignment runs. Originally two separate slots; merged to save one allocation and one parameter per + /// async invocation. + /// + /// + /// Faulted/cancelled await is parked here so the body's rewriter can rethrow it at the resume point via . + /// + /// + /// Caller's cancellation token. Sampled at each loop iteration and linked to each + /// awaited task; see remarks for the cancellation model. + /// /// - /// This method is itself an async Task whose IL is produced by - /// Roslyn on .NET 11 under <Features>runtime-async=on</Features>, - /// so each await below becomes a real .NET 11 runtime-async opcode. - /// The state machine of the language function body is delegated to a - /// GeneratorRewriter-produced of yielded - /// tasks. + /// This method is itself an async Task whose IL is produced by Roslyn on .NET 11 under + /// <Features>runtime-async=on</Features>, so each await below becomes a real .NET 11 runtime-async + /// opcode. The state machine of the language function body is delegated to a GeneratorRewriter-produced of yielded tasks. On older runtimes, the same method is compiled by Roslyn without runtime-async + /// support, so it compiles to a Roslyn-generated async state machine. /// - /// Continuation scheduling is determined by the caller's - /// / — the - /// awaits here do not call ConfigureAwait(false), so a host that - /// installs a single-threaded context (e.g. an asyncio-style event loop) - /// pins every resumption to its loop thread. + /// Continuation scheduling is determined by the caller's / + /// — the awaits here do not call ConfigureAwait(false), so a host that installs a single-threaded context (e.g. an + /// asyncio-style event loop) pins every resumption to its loop thread. /// - /// Cancellation is cooperative. - /// is sampled at each loop iteration (so a stretch of synchronously-resolved - /// awaits is still cancellable) and is linked to each suspended task so a - /// cancellation request unblocks an in-flight await even when the awaited - /// task does not honor the token itself. If cancellation fires during an - /// await, the resulting is - /// routed through exactly like any other - /// awaited fault, so a body-level try/except around the await can - /// observe it; otherwise it bubbles out and the returned Task transitions - /// to because the OCE's token matches. + /// Cancellation is cooperative and follows asyncio's model: is sampled at each loop + /// iteration (so a stretch of synchronously-resolved awaits is still cancellable) and is linked to each suspended task so a + /// cancellation request unblocks an in-flight await even when the awaited task does not honor the token itself. In either case + /// the resulting is routed through so it surfaces at + /// the body's next resume point — exactly the place a body-level try/except around the await expects to observe it. + /// If the body lets the exception propagate, it bubbles out of and the returned Task transitions to because the OCE's token matches. /// public static async Task Drive( IEnumerator states, - StrongBox resultSlot, - StrongBox returnSlot, + StrongBox valueSlot, StrongBox exceptionSlot, CancellationToken cancellationToken = default) { while (states.MoveNext()) { - // Sample between iterations: catches cancellation requested during a stretch of synchronously-resolved yields, - // and is the (uncatchable) interruption point Python try/except cannot guard. - cancellationToken.ThrowIfCancellationRequested(); - object yielded = states.Current; + + // Surface cancellation at the just-yielded suspension point so the body's try/except around + // `await` can observe it (matches asyncio's "CancelledError raised at the await" model). + // Caught here as well as in the WaitAsync branch so that a stretch of synchronously-resolved + // yields is still cancellable. + if (cancellationToken.IsCancellationRequested) { + valueSlot.Value = null; + exceptionSlot.Value = new OperationCanceledException(cancellationToken); + continue; + } + if (yielded is Task task) { try { // Task.WaitAsync is BCL on net6+, polyfilled by Meziantou.Polyfill on net4x/netstandard2.0. // No ConfigureAwait(false): honor caller's SyncContext / TaskScheduler. await task.WaitAsync(cancellationToken); - resultSlot.Value = ExtractTaskResult(task); + valueSlot.Value = ExtractTaskResult(task); exceptionSlot.Value = null; } catch (Exception ex) { // The body's rewriter inserts a check after each yield that rethrows this on the resumed thread, // so a try/except inside `await` can catch it (including the cancellation OCE from WaitAsync). - resultSlot.Value = null; + valueSlot.Value = null; exceptionSlot.Value = ex; } } else { // Synchronously-produced value forwards straight through. - resultSlot.Value = yielded; + valueSlot.Value = yielded; exceptionSlot.Value = null; } } - return returnSlot.Value; + // Body has completed: its final assignment has just written into valueSlot + // (see AsyncBodyExpression.BuildReduction), so the per-await role of the slot + // is over and the same slot now carries the function's return value. + return valueSlot.Value; } + private static object? ExtractTaskResult(Task task) { // The runtime type may be a Task subclass (e.g. AsyncStateMachineBox, or RuntimeAsyncTask); // so find Task.Result through hierarchy From 4616f29ab49807201f5f7043cd681c971f25d0ec Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Mon, 18 May 2026 20:47:32 -0700 Subject: [PATCH 06/14] Normalize naming of async supporting types --- ...ncBodyExpression.cs => AsyncExpression.cs} | 22 +++++++++---------- .../Microsoft.Dynamic/Ast/AwaitExpression.cs | 4 ++-- .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) rename src/core/Microsoft.Dynamic/Ast/{AsyncBodyExpression.cs => AsyncExpression.cs} (91%) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs similarity index 91% rename from src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs rename to src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index e0cb76c6..976a0850 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncBodyExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -37,10 +37,10 @@ namespace Microsoft.Scripting.Ast { /// method when the project sets <Features>runtime-async=on</Features> /// on .NET 11+. /// - public sealed class AsyncBodyExpression : Expression { + public sealed class AsyncExpression : Expression { private Expression? _reduced; - internal AsyncBodyExpression(string? name, Expression body, Expression cancellationToken) { + internal AsyncExpression(string? name, Expression body, Expression cancellationToken) { Name = name; Body = body; CancellationToken = cancellationToken; @@ -95,7 +95,7 @@ private Expression BuildReduction() { // After the body completes, the function's final value must live in valueSlot for Drive to pick up. For a value-typed body this // is a single assignment of the body expression into the slot. For a void body, the body has no value to assign, but we still // must clear the slot — otherwise Drive would return whatever the last await happened to stash there. (IronPython doesn't emit - // void async bodies today, but AsyncBodyExpression is language-agnostic.) + // void async bodies today, but AsyncExpression is language-agnostic.) Expression valueField = Expression.Field(valueSlot, nameof(StrongBox.Value)); Expression captureFinalValue; if (Body.Type == typeof(void)) { @@ -137,7 +137,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { Expression b = visitor.Visit(Body); Expression ct = visitor.Visit(CancellationToken); if (b == Body && ct == CancellationToken) return this; - return new AsyncBodyExpression(Name, b, ct); + return new AsyncExpression(Name, b, ct); } private sealed class AwaitToYieldRewriter : ExpressionVisitor { @@ -186,27 +186,27 @@ protected override Expression VisitExtension(Expression node) { public partial class Utils { /// - /// Wraps an async-function body in an . + /// Wraps an async-function body in an . /// The body may contain suspension points /// and should evaluate to ; the resulting expression /// evaluates to Task<object>. Cancellation defaults to /// default(CancellationToken); use the - /// overload to + /// overload to /// supply one. /// - public static AsyncBodyExpression AsyncBody(string? name, Expression body) { + public static AsyncExpression Async(string? name, Expression body) { ContractUtils.RequiresNotNull(body, nameof(body)); - return new AsyncBodyExpression(name, body, Expression.Default(typeof(CancellationToken))); + return new AsyncExpression(name, body, Expression.Default(typeof(CancellationToken))); } /// - /// Wraps an async-function body in an + /// Wraps an async-function body in an /// with a caller-provided . /// The token expression is evaluated once when the body starts and is /// then sampled by between iterations /// and at each suspended await. /// - public static AsyncBodyExpression AsyncBody(string? name, Expression body, Expression cancellationToken) { + public static AsyncExpression Async(string? name, Expression body, Expression cancellationToken) { ContractUtils.RequiresNotNull(body, nameof(body)); ContractUtils.RequiresNotNull(cancellationToken, nameof(cancellationToken)); if (cancellationToken.Type != typeof(CancellationToken)) { @@ -214,7 +214,7 @@ public static AsyncBodyExpression AsyncBody(string? name, Expression body, Expre $"Expression must evaluate to {nameof(CancellationToken)}, got {cancellationToken.Type}.", nameof(cancellationToken)); } - return new AsyncBodyExpression(name, body, cancellationToken); + return new AsyncExpression(name, body, cancellationToken); } } } diff --git a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs index 35f526f4..3b66db3f 100644 --- a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs @@ -11,7 +11,7 @@ namespace Microsoft.Scripting.Ast { /// - /// A suspension point inside an . The + /// A suspension point inside an . The /// is expected to evaluate to a /// or . /// @@ -21,7 +21,7 @@ namespace Microsoft.Scripting.Ast { /// task to the runner, which performs a real runtime-async await and /// resumes the body with the boxed result. ///
- /// Standalone reduction is not supported - + /// Standalone reduction is not supported - /// rewrites these nodes into yield+resume pairs before the body is lowered. /// public sealed class AwaitExpression : Expression { diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index 6a60f272..adc92ce8 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -13,7 +13,7 @@ namespace Microsoft.Scripting.Runtime { /// - /// Runtime-async orchestrator for . + /// Runtime-async orchestrator for . /// public static class AsyncRunner { /// @@ -94,7 +94,7 @@ public static class AsyncRunner { } } // Body has completed: its final assignment has just written into valueSlot - // (see AsyncBodyExpression.BuildReduction), so the per-await role of the slot + // (see AsyncExpression.BuildReduction), so the per-await role of the slot // is over and the same slot now carries the function's return value. return valueSlot.Value; } From dfc50d7dd9351423e3a50a9f0030353d8ffe310b Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Mon, 18 May 2026 21:10:26 -0700 Subject: [PATCH 07/14] Add a fast track for a typical IronPython case --- .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 36 +++++++++++++------ 1 file changed, 26 insertions(+), 10 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index adc92ce8..ea96dcaf 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -56,11 +56,11 @@ public static class AsyncRunner { /// If the body lets the exception propagate, it bubbles out of and the returned Task transitions to because the OCE's token matches. /// - public static async Task Drive( - IEnumerator states, - StrongBox valueSlot, - StrongBox exceptionSlot, - CancellationToken cancellationToken = default) { + public static async Task Drive(IEnumerator states, + StrongBox valueSlot, + StrongBox exceptionSlot, + CancellationToken cancellationToken = default) { + while (states.MoveNext()) { object yielded = states.Current; @@ -101,12 +101,28 @@ public static class AsyncRunner { private static object? ExtractTaskResult(Task task) { + // Fast-track for the most common case (e.g. IronPython): all awaitables are normalized to Task. + if (task is Task to) return to.Result; + + Type t = task.GetType(); + + // Non-generic Task subclass: no Result property exists. Covers Task.CompletedTask, Task.Delay's DelayPromise, + // Task.WhenAll's non-generic overload, etc. IsGenericType is a flag check on Type — far cheaper than GetProperty. + if (!t.IsGenericType) return null; + // The runtime type may be a Task subclass (e.g. AsyncStateMachineBox, or RuntimeAsyncTask); - // so find Task.Result through hierarchy - var prop = task.GetType().GetProperty("Result"); // this may be incorrect in the unlikely (and bad) case if the subclass shadows Result (.e.g new T2 Result {...}, not in BCL/CLR) - if (prop is null) return null; // non-generic Task - if (!prop.PropertyType.IsVisible) return null; // Roslyn-emitted or CLR async Task uses an internal VoidTaskResult type argument; surface that as null. - return prop.GetValue(task); // Task.Result, may be null (and is null if task has not completed yet) + // so find Task.Result through inheritance hierarchy. + // This may be incorrect in the unlikely (and bad) case if the subclass shadows Result (.e.g new T2 Result {...} - not in BCL/CLR) + var prop = t.GetProperty("Result"); + + // Non-generic Task subclass that still wasn't caught above (defensive — shouldn't happen given IsGenericType). + if (prop is null) return null; + + // Roslyn-emitted or CLR async Task uses an internal VoidTaskResult type argument; surface that as null. + if (!prop.PropertyType.IsVisible) return null; + + // Task.Result, may be null, which is OK (and is null if task has not completed yet; not happening here) + return prop.GetValue(task); } } } From 439ba7654240686ffa86ceccafd36f61cffda1ec Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Mon, 18 May 2026 21:35:07 -0700 Subject: [PATCH 08/14] Pre-cache static reflection lookups --- .../Microsoft.Dynamic/Ast/AsyncExpression.cs | 26 ++++++++++++++----- .../Microsoft.Dynamic/Runtime/AsyncRunner.cs | 7 ++--- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index 976a0850..a15bd7ce 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -38,6 +38,20 @@ namespace Microsoft.Scripting.Ast { /// on .NET 11+. /// public sealed class AsyncExpression : Expression { + // Cached reflection, looked up once at type init rather than on every Reduce(). + // Use string literal "Drive" instead of nameof(AsyncRunner.Drive): the latter binds + // to the [Obsolete(error: true)] member and triggers a compile error. + private static readonly MethodInfo s_driveMethod + = typeof(AsyncRunner).GetMethod("Drive")!; + private static readonly FieldInfo s_valueSlotField + = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; + private static readonly FieldInfo s_exceptionSlotField + = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; + private static readonly ConstructorInfo s_valueSlotCtor + = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + private static readonly ConstructorInfo s_exceptionSlotCtor + = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + private Expression? _reduced; internal AsyncExpression(string? name, Expression body, Expression cancellationToken) { @@ -96,7 +110,7 @@ private Expression BuildReduction() { // is a single assignment of the body expression into the slot. For a void body, the body has no value to assign, but we still // must clear the slot — otherwise Drive would return whatever the last await happened to stash there. (IronPython doesn't emit // void async bodies today, but AsyncExpression is language-agnostic.) - Expression valueField = Expression.Field(valueSlot, nameof(StrongBox.Value)); + Expression valueField = Expression.Field(valueSlot, s_valueSlotField); Expression captureFinalValue; if (Body.Type == typeof(void)) { captureFinalValue = Expression.Block( @@ -119,7 +133,7 @@ private Expression BuildReduction() { rewriteAssignments: false); Expression drive = Expression.Call( - typeof(AsyncRunner).GetMethod(nameof(AsyncRunner.Drive))!, + s_driveMethod, generator, valueSlot, exceptionSlot, @@ -128,8 +142,8 @@ private Expression BuildReduction() { return Expression.Block( typeof(Task), [ valueSlot, exceptionSlot ], - Expression.Assign(valueSlot, Expression.New(typeof(StrongBox))), - Expression.Assign(exceptionSlot, Expression.New(typeof(StrongBox))), + Expression.Assign(valueSlot, Expression.New(s_valueSlotCtor)), + Expression.Assign(exceptionSlot, Expression.New(s_exceptionSlotCtor)), drive); } @@ -160,8 +174,8 @@ protected override Expression VisitExtension(Expression node) { if (node is AwaitExpression aw) { Expression operand = Visit(aw.Operand); Expression boxed = operand.Type == typeof(object) ? operand : Expression.Convert(operand, typeof(object)); - Expression readException = Expression.Field(_exceptionSlot, nameof(StrongBox.Value)); - Expression readSlot = Expression.Field(_resultSlot, nameof(StrongBox.Value)); + Expression readException = Expression.Field(_exceptionSlot, s_exceptionSlotField); + Expression readSlot = Expression.Field(_resultSlot, s_valueSlotField); // After the yield, if the runner stored an exception, rethrow it // preserving the original stack trace (so the body's try/except diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs index ea96dcaf..f210660c 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs @@ -38,8 +38,8 @@ public static class AsyncRunner { /// awaited task; see remarks for the cancellation model. /// /// - /// This method is itself an async Task whose IL is produced by Roslyn on .NET 11 under - /// <Features>runtime-async=on</Features>, so each await below becomes a real .NET 11 runtime-async + /// This method is itself an async Task whose IL is produced by Roslyn on .NET 11+ under + /// <Features>runtime-async=on</Features>, so each await below becomes a real .NET 11+ runtime-async /// opcode. The state machine of the language function body is delegated to a GeneratorRewriter-produced of yielded tasks. On older runtimes, the same method is compiled by Roslyn without runtime-async /// support, so it compiles to a Roslyn-generated async state machine. @@ -56,6 +56,7 @@ public static class AsyncRunner { /// If the body lets the exception propagate, it bubbles out of and the returned Task transitions to because the OCE's token matches. /// + [Obsolete("Do not call this method directly from source level code", error: true)] public static async Task Drive(IEnumerator states, StrongBox valueSlot, StrongBox exceptionSlot, @@ -112,7 +113,7 @@ public static class AsyncRunner { // The runtime type may be a Task subclass (e.g. AsyncStateMachineBox, or RuntimeAsyncTask); // so find Task.Result through inheritance hierarchy. - // This may be incorrect in the unlikely (and bad) case if the subclass shadows Result (.e.g new T2 Result {...} - not in BCL/CLR) + // This may be incorrect in the unlikely (and bad) case if the subclass shadows Result (e.g. new T2 Result {...} - not in BCL/CLR) var prop = t.GetProperty("Result"); // Non-generic Task subclass that still wasn't caught above (defensive — shouldn't happen given IsGenericType). From bd21740a56c7fb4bc979172080aedab54bdecfce Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Mon, 18 May 2026 21:58:26 -0700 Subject: [PATCH 09/14] Factor out AsyncRewriter --- .../Microsoft.Dynamic/Ast/AsyncExpression.cs | 171 +++--------------- .../Microsoft.Dynamic/Ast/AsyncRewriter.cs | 144 +++++++++++++++ 2 files changed, 168 insertions(+), 147 deletions(-) create mode 100644 src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index a15bd7ce..21f943f3 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -5,11 +5,7 @@ #nullable enable using System; -using System.Collections.Generic; using System.Linq.Expressions; -using System.Reflection; -using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Tasks; @@ -31,27 +27,12 @@ namespace Microsoft.Scripting.Ast { /// The body is expected to evaluate to an (can be null): /// that value becomes the Task's result. ///
- /// State-machine splitting at await sites is delegated to ; - /// the runtime-async await opcodes come from + /// State-machine splitting at await sites is delegated to + /// via ; the runtime-async await opcodes come from /// , which Roslyn compiles into a runtime-async - /// method when the project sets <Features>runtime-async=on</Features> - /// on .NET 11+. + /// method when the project sets runtime-async=on as a feature flag on .NET 11+. /// public sealed class AsyncExpression : Expression { - // Cached reflection, looked up once at type init rather than on every Reduce(). - // Use string literal "Drive" instead of nameof(AsyncRunner.Drive): the latter binds - // to the [Obsolete(error: true)] member and triggers a compile error. - private static readonly MethodInfo s_driveMethod - = typeof(AsyncRunner).GetMethod("Drive")!; - private static readonly FieldInfo s_valueSlotField - = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; - private static readonly FieldInfo s_exceptionSlotField - = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; - private static readonly ConstructorInfo s_valueSlotCtor - = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; - private static readonly ConstructorInfo s_exceptionSlotCtor - = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; - private Expression? _reduced; internal AsyncExpression(string? name, Expression body, Expression cancellationToken) { @@ -60,16 +41,19 @@ internal AsyncExpression(string? name, Expression body, Expression cancellationT CancellationToken = cancellationToken; } - /// Optional diagnostic name (forwarded to the inner generator). + /// + /// Optional diagnostic name (forwarded to the inner generator). + /// public string? Name { get; } - /// The function body. May contain nodes. + /// + /// The function body. May contain nodes. + /// public Expression Body { get; } /// - /// Expression evaluating to a - /// that samples between iterations and links - /// to each suspended task. Defaults to default(CancellationToken). + /// Expression evaluating to a that samples + /// between iterations and links to each suspended task. Defaults to default(CancellationToken). /// public Expression CancellationToken { get; } @@ -80,71 +64,7 @@ internal AsyncExpression(string? name, Expression body, Expression cancellationT public override ExpressionType NodeType => ExpressionType.Extension; public override Expression Reduce() { - // Cache the reduction so that LabelTarget identity is preserved across the multiple Reduce() invocations the compiler may make - // (closure analysis, IL emission, etc.). Without this the inner GeneratorRewriter sees yields whose Target was minted on a - // different Reduce() call than the surrounding generator. - return _reduced ??= BuildReduction(); - } - - private Expression BuildReduction() { - // valueSlot is value cell shared with AsyncRunner.Drive. - // - At each await: the runner writes the awaited result here just before resuming the body, and the body reads it via the - // `readSlot` expression the rewriter inserts after each yield. - // - At the end of the body: the body's final return value is written here (see captureFinalValue below). After MoveNext() - // returns false, Drive reads the same slot and returns it as the Task's result. - // - // The two uses do not overlap — by the time captureFinalValue runs, the last per-await read has already been consumed or - // discarded by the surrounding expression. - ParameterExpression valueSlot = Expression.Variable(typeof(StrongBox), "$asyncValue"); - - // exceptionSlot is where the runner stashes a faulted-await exception so the state machine can rethrow at the resumed position - // (lets a Python try/except around `await` observe e.g. StopAsyncIteration). - ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); - LabelTarget yieldLabel = Expression.Label(typeof(object), "$asyncYield"); - - // Rewrite AwaitExpression(e) -> { yield e; rethrow-if-pending; valueSlot.Value } - var rewriter = new AwaitToYieldRewriter(yieldLabel, valueSlot, exceptionSlot); - Expression rewrittenBody = rewriter.Visit(Body); - - // After the body completes, the function's final value must live in valueSlot for Drive to pick up. For a value-typed body this - // is a single assignment of the body expression into the slot. For a void body, the body has no value to assign, but we still - // must clear the slot — otherwise Drive would return whatever the last await happened to stash there. (IronPython doesn't emit - // void async bodies today, but AsyncExpression is language-agnostic.) - Expression valueField = Expression.Field(valueSlot, s_valueSlotField); - Expression captureFinalValue; - if (Body.Type == typeof(void)) { - captureFinalValue = Expression.Block( - typeof(void), - rewrittenBody, - Expression.Assign(valueField, Expression.Constant(null, typeof(object)))); - } else { - Expression asObject = rewrittenBody.Type == typeof(object) - ? rewrittenBody - : Expression.Convert(rewrittenBody, typeof(object)); - captureFinalValue = Expression.Assign(valueField, asObject); - } - Expression generatorBody = Expression.Block(typeof(void), captureFinalValue); - - Expression generator = Utils.Generator( - Name ?? "$async", - yieldLabel, - generatorBody, - typeof(IEnumerator), - rewriteAssignments: false); - - Expression drive = Expression.Call( - s_driveMethod, - generator, - valueSlot, - exceptionSlot, - CancellationToken); - - return Expression.Block( - typeof(Task), - [ valueSlot, exceptionSlot ], - Expression.Assign(valueSlot, Expression.New(s_valueSlotCtor)), - Expression.Assign(exceptionSlot, Expression.New(s_exceptionSlotCtor)), - drive); + return _reduced ??= new AsyncRewriter(this).Reduce(); } protected override Expression VisitChildren(ExpressionVisitor visitor) { @@ -153,73 +73,30 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { if (b == Body && ct == CancellationToken) return this; return new AsyncExpression(Name, b, ct); } - - private sealed class AwaitToYieldRewriter : ExpressionVisitor { - private static readonly MethodInfo s_captureMethod - = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Capture))!; - private static readonly MethodInfo s_throwMethod - = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Throw), Type.EmptyTypes)!; - - private readonly LabelTarget _yieldLabel; - private readonly ParameterExpression _resultSlot; - private readonly ParameterExpression _exceptionSlot; - - public AwaitToYieldRewriter(LabelTarget yieldLabel, ParameterExpression resultSlot, ParameterExpression exceptionSlot) { - _yieldLabel = yieldLabel; - _resultSlot = resultSlot; - _exceptionSlot = exceptionSlot; - } - - protected override Expression VisitExtension(Expression node) { - if (node is AwaitExpression aw) { - Expression operand = Visit(aw.Operand); - Expression boxed = operand.Type == typeof(object) ? operand : Expression.Convert(operand, typeof(object)); - Expression readException = Expression.Field(_exceptionSlot, s_exceptionSlotField); - Expression readSlot = Expression.Field(_resultSlot, s_valueSlotField); - - // After the yield, if the runner stored an exception, rethrow it - // preserving the original stack trace (so the body's try/except - // observes the right exception object). Otherwise return the - // awaited result. - Expression rethrow = Expression.IfThen( - Expression.ReferenceNotEqual(readException, Expression.Constant(null, typeof(Exception))), - Expression.Call( - Expression.Call(s_captureMethod, readException), - s_throwMethod)); - - return Expression.Block( - typeof(object), - Utils.YieldReturn(_yieldLabel, boxed), - rethrow, - readSlot); - } - return base.VisitExtension(node); - } - } } public partial class Utils { /// - /// Wraps an async-function body in an . - /// The body may contain suspension points - /// and should evaluate to ; the resulting expression - /// evaluates to Task<object>. Cancellation defaults to - /// default(CancellationToken); use the - /// overload to - /// supply one. + /// Wraps an async-function body in an . /// + /// + /// The body may contain suspension points and should evaluate to ; the + /// resulting expression evaluates to Task<object>. Cancellation defaults to default(CancellationToken); use + /// the overload to supply one. + /// public static AsyncExpression Async(string? name, Expression body) { ContractUtils.RequiresNotNull(body, nameof(body)); return new AsyncExpression(name, body, Expression.Default(typeof(CancellationToken))); } /// - /// Wraps an async-function body in an - /// with a caller-provided . - /// The token expression is evaluated once when the body starts and is - /// then sampled by between iterations - /// and at each suspended await. + /// Wraps an async-function body in an + /// with a caller-provided . /// + /// + /// The token expression is evaluated once when the body starts and is then sampled by between + /// iterations and at each suspended await. + /// public static AsyncExpression Async(string? name, Expression body, Expression cancellationToken) { ContractUtils.RequiresNotNull(body, nameof(body)); ContractUtils.RequiresNotNull(cancellationToken, nameof(cancellationToken)); diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs new file mode 100644 index 00000000..f69e12f6 --- /dev/null +++ b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs @@ -0,0 +1,144 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +using Microsoft.Scripting.Runtime; + +namespace Microsoft.Scripting.Ast { + /// + /// Reduces an to a Task<object>-valued + /// expression tree that yields each 's operand and + /// hands the resulting state machine to . + /// + internal sealed class AsyncRewriter { + private static readonly MethodInfo s_driveMethod + = typeof(AsyncRunner).GetMethod("Drive")!; + private static readonly FieldInfo s_valueSlotField + = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; + private static readonly FieldInfo s_exceptionSlotField + = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; + private static readonly ConstructorInfo s_valueSlotCtor + = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + private static readonly ConstructorInfo s_exceptionSlotCtor + = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + + private readonly AsyncExpression _node; + + public AsyncRewriter(AsyncExpression node) { + _node = node; + } + + public Expression Reduce() { + // valueSlot is value cell shared with AsyncRunner.Drive. + // - At each await: the runner writes the awaited result here just before resuming the body, and the body reads it via the + // `readSlot` expression the rewriter inserts after each yield. + // - At the end of the body: the body's final return value is written here (see captureFinalValue below). After MoveNext() + // returns false, Drive reads the same slot and returns it as the Task's result. + // + // The two uses do not overlap — by the time captureFinalValue runs, the last per-await read has already been consumed or + // discarded by the surrounding expression. + ParameterExpression valueSlot = Expression.Variable(typeof(StrongBox), "$asyncValue"); + + // exceptionSlot is where the runner stashes a faulted-await exception so the state machine can rethrow at the resumed position + // (lets a Python try/except around `await` observe e.g. StopAsyncIteration). + ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); + LabelTarget yieldLabel = Expression.Label(typeof(object), "$asyncYield"); + + // Rewrite AwaitExpression(e) -> { yield e; rethrow-if-pending; valueSlot.Value } + var rewriter = new AwaitToYieldRewriter(yieldLabel, valueSlot, exceptionSlot); + Expression rewrittenBody = rewriter.Visit(_node.Body); + + // After the body completes, the function's final value must live in valueSlot for Drive to pick up. For a value-typed body this + // is a single assignment of the body expression into the slot. For a void body, the body has no value to assign, but we still + // must clear the slot — otherwise Drive would return whatever the last await happened to stash there. (IronPython doesn't emit + // void async bodies today, but AsyncExpression is language-agnostic.) + Expression valueField = Expression.Field(valueSlot, s_valueSlotField); + Expression captureFinalValue; + if (_node.Body.Type == typeof(void)) { + captureFinalValue = Expression.Block( + typeof(void), + rewrittenBody, + Expression.Assign(valueField, Expression.Constant(null, typeof(object)))); + } else { + Expression asObject = rewrittenBody.Type == typeof(object) + ? rewrittenBody + : Expression.Convert(rewrittenBody, typeof(object)); + captureFinalValue = Expression.Assign(valueField, asObject); + } + Expression generatorBody = Expression.Block(typeof(void), captureFinalValue); + + Expression generator = Utils.Generator( + _node.Name ?? "$async", + yieldLabel, + generatorBody, + typeof(IEnumerator), + rewriteAssignments: false); + + Expression drive = Expression.Call( + s_driveMethod, + generator, + valueSlot, + exceptionSlot, + _node.CancellationToken); + + return Expression.Block( + typeof(Task), + [ valueSlot, exceptionSlot ], + Expression.Assign(valueSlot, Expression.New(s_valueSlotCtor)), + Expression.Assign(exceptionSlot, Expression.New(s_exceptionSlotCtor)), + drive); + } + + private sealed class AwaitToYieldRewriter : ExpressionVisitor { + private static readonly MethodInfo s_captureMethod + = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Capture))!; + private static readonly MethodInfo s_throwMethod + = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Throw), Type.EmptyTypes)!; + + private readonly LabelTarget _yieldLabel; + private readonly ParameterExpression _resultSlot; + private readonly ParameterExpression _exceptionSlot; + + public AwaitToYieldRewriter(LabelTarget yieldLabel, ParameterExpression resultSlot, ParameterExpression exceptionSlot) { + _yieldLabel = yieldLabel; + _resultSlot = resultSlot; + _exceptionSlot = exceptionSlot; + } + + protected override Expression VisitExtension(Expression node) { + if (node is AwaitExpression aw) { + Expression operand = Visit(aw.Operand); + Expression boxed = operand.Type == typeof(object) ? operand : Expression.Convert(operand, typeof(object)); + Expression readException = Expression.Field(_exceptionSlot, s_exceptionSlotField); + Expression readSlot = Expression.Field(_resultSlot, s_valueSlotField); + + // After the yield, if the runner stored an exception, rethrow it preserving the original stack trace + // (so the body's try/except observes the right exception object). + // Otherwise return the awaited result. + Expression rethrow = Expression.IfThen( + Expression.ReferenceNotEqual(readException, Expression.Constant(null, typeof(Exception))), + Expression.Call( + Expression.Call(s_captureMethod, readException), + s_throwMethod)); + + return Expression.Block( + typeof(object), + Utils.YieldReturn(_yieldLabel, boxed), + rethrow, + readSlot); + } + return base.VisitExtension(node); + } + } + } +} From 7367c86a2c33acd8cb8996c3d7f3ae031066a965 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Tue, 19 May 2026 14:31:08 -0700 Subject: [PATCH 10/14] Rename AsyncRunner to AsyncHelpers --- .../Microsoft.Dynamic/Ast/AsyncExpression.cs | 15 +++++------ .../Microsoft.Dynamic/Ast/AsyncRewriter.cs | 14 +++++----- .../Microsoft.Dynamic/Ast/AwaitExpression.cs | 2 +- .../{AsyncRunner.cs => AsyncHelpers.cs} | 26 ++++++++++--------- 4 files changed, 28 insertions(+), 29 deletions(-) rename src/core/Microsoft.Dynamic/Runtime/{AsyncRunner.cs => AsyncHelpers.cs} (87%) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index 21f943f3..fc765814 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -28,9 +28,8 @@ namespace Microsoft.Scripting.Ast { /// that value becomes the Task's result. ///
/// State-machine splitting at await sites is delegated to - /// via ; the runtime-async await opcodes come from - /// , which Roslyn compiles into a runtime-async - /// method when the project sets runtime-async=on as a feature flag on .NET 11+. + /// via ; the await handling comes from + /// . /// public sealed class AsyncExpression : Expression { private Expression? _reduced; @@ -52,7 +51,7 @@ internal AsyncExpression(string? name, Expression body, Expression cancellationT public Expression Body { get; } /// - /// Expression evaluating to a that samples + /// Expression evaluating to a that samples /// between iterations and links to each suspended task. Defaults to default(CancellationToken). /// public Expression CancellationToken { get; } @@ -90,12 +89,12 @@ public static AsyncExpression Async(string? name, Expression body) { } /// - /// Wraps an async-function body in an - /// with a caller-provided . + /// Wraps an async-function body in an with a caller-provided . /// /// - /// The token expression is evaluated once when the body starts and is then sampled by between - /// iterations and at each suspended await. + /// The token expression is evaluated once when the body starts and is then sampled by + /// between iterations and at each suspended await. /// public static AsyncExpression Async(string? name, Expression body, Expression cancellationToken) { ContractUtils.RequiresNotNull(body, nameof(body)); diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs index f69e12f6..d813e546 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs @@ -12,17 +12,15 @@ using System.Runtime.ExceptionServices; using System.Threading.Tasks; -using Microsoft.Scripting.Runtime; - namespace Microsoft.Scripting.Ast { /// /// Reduces an to a Task<object>-valued /// expression tree that yields each 's operand and - /// hands the resulting state machine to . + /// hands the resulting state machine to . /// internal sealed class AsyncRewriter { private static readonly MethodInfo s_driveMethod - = typeof(AsyncRunner).GetMethod("Drive")!; + = typeof(Microsoft.Scripting.Runtime.AsyncHelpers).GetMethod("DriveAsync")!; private static readonly FieldInfo s_valueSlotField = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; private static readonly FieldInfo s_exceptionSlotField @@ -39,11 +37,11 @@ public AsyncRewriter(AsyncExpression node) { } public Expression Reduce() { - // valueSlot is value cell shared with AsyncRunner.Drive. + // valueSlot is value cell shared with AsyncHelpers.DriveAsync. // - At each await: the runner writes the awaited result here just before resuming the body, and the body reads it via the // `readSlot` expression the rewriter inserts after each yield. // - At the end of the body: the body's final return value is written here (see captureFinalValue below). After MoveNext() - // returns false, Drive reads the same slot and returns it as the Task's result. + // returns false, DriveAsync reads the same slot and returns it as the Task's result. // // The two uses do not overlap — by the time captureFinalValue runs, the last per-await read has already been consumed or // discarded by the surrounding expression. @@ -58,9 +56,9 @@ public Expression Reduce() { var rewriter = new AwaitToYieldRewriter(yieldLabel, valueSlot, exceptionSlot); Expression rewrittenBody = rewriter.Visit(_node.Body); - // After the body completes, the function's final value must live in valueSlot for Drive to pick up. For a value-typed body this + // After the body completes, the function's final value must live in valueSlot for DriveAsync to pick up. For a value-typed body this // is a single assignment of the body expression into the slot. For a void body, the body has no value to assign, but we still - // must clear the slot — otherwise Drive would return whatever the last await happened to stash there. (IronPython doesn't emit + // must clear the slot — otherwise DriveAsync would return whatever the last await happened to stash there. (IronPython doesn't emit // void async bodies today, but AsyncExpression is language-agnostic.) Expression valueField = Expression.Field(valueSlot, s_valueSlotField); Expression captureFinalValue; diff --git a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs index 3b66db3f..a40fd2cb 100644 --- a/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AwaitExpression.cs @@ -16,7 +16,7 @@ namespace Microsoft.Scripting.Ast { /// or . /// /// - /// The drives + /// The drives /// the lambda by calling its GeneratorNext form; each await yields the /// task to the runner, which performs a real runtime-async await and /// resumes the body with the boxed result. diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs similarity index 87% rename from src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs rename to src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs index f210660c..79e46d26 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncRunner.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs @@ -15,7 +15,7 @@ namespace Microsoft.Scripting.Runtime { /// /// Runtime-async orchestrator for . /// - public static class AsyncRunner { + public static class AsyncHelpers { /// /// Drives a language function body, awaiting each yielded and feeding the result back through . Faulted awaits are routed through so the body's try/except can observe @@ -23,11 +23,10 @@ public static class AsyncRunner { /// /// The body's state machine — an enumerator of yielded awaitables. /// - /// While the body is running, this is where the runner writes each awaited result before resuming the body (read by the body at + /// While the body is running, this is where the driver writes each awaited result before resuming the body (read by the body at /// the resume point of each await). After the body completes, this is where the body has stored its final return value, /// which becomes the returned Task's result. The two uses do not overlap: the body has consumed (or discarded) any per-await - /// value before its final assignment runs. Originally two separate slots; merged to save one allocation and one parameter per - /// async invocation. + /// value before its final assignment runs. /// /// /// Faulted/cancelled await is parked here so the body's rewriter can rethrow it at the resume point via /// - /// This method is itself an async Task whose IL is produced by Roslyn on .NET 11+ under - /// <Features>runtime-async=on</Features>, so each await below becomes a real .NET 11+ runtime-async + /// This method is itself an async Task whose IL is produced by Roslyn (on .NET 11+, under feature + /// runtime-async=on), so each await below becomes a real .NET 11+ runtime-async /// opcode. The state machine of the language function body is delegated to a GeneratorRewriter-produced of yielded tasks. On older runtimes, the same method is compiled by Roslyn without runtime-async /// support, so it compiles to a Roslyn-generated async state machine. @@ -53,14 +52,17 @@ public static class AsyncRunner { /// cancellation request unblocks an in-flight await even when the awaited task does not honor the token itself. In either case /// the resulting is routed through so it surfaces at /// the body's next resume point — exactly the place a body-level try/except around the await expects to observe it. - /// If the body lets the exception propagate, it bubbles out of and the returned Task transitions to and the returned Task transitions to because the OCE's token matches. + /// + /// This method is not obsolete but is not part of the public API surface; do not call it directly from source-level code. + /// It is made public only for internal use by the DLR. /// - [Obsolete("Do not call this method directly from source level code", error: true)] - public static async Task Drive(IEnumerator states, - StrongBox valueSlot, - StrongBox exceptionSlot, - CancellationToken cancellationToken = default) { + [Obsolete("do not call this method directly from source-level code", error: true)] + public static async Task DriveAsync(IEnumerator states, + StrongBox valueSlot, + StrongBox exceptionSlot, + CancellationToken cancellationToken = default) { while (states.MoveNext()) { object yielded = states.Current; From 172bea1f89f5af1949c03c53acb0b7b8a7b8da94 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Wed, 20 May 2026 16:27:04 -0700 Subject: [PATCH 11/14] Support cancellation exceptions in async expressions --- .../Microsoft.Dynamic/Ast/AsyncExpression.cs | 74 ++++++++++++++----- .../Microsoft.Dynamic/Ast/AsyncRewriter.cs | 3 +- .../Microsoft.Dynamic/Runtime/AsyncHelpers.cs | 21 ++++-- 3 files changed, 73 insertions(+), 25 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index fc765814..8047a11c 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -6,6 +6,7 @@ using System; using System.Linq.Expressions; +using System.Runtime.CompilerServices; using System.Threading; using System.Threading.Tasks; @@ -24,8 +25,10 @@ namespace Microsoft.Scripting.Ast { /// direct access to parameters and locals of the enclosing scope. Callers /// typically wrap the resulting Task in their own coroutine façade type. ///
- /// The body is expected to evaluate to an (can be null): - /// that value becomes the Task's result. + /// The body may evaluate to any type, including . A + /// body produces a Task result; + /// any other type is converted to (value types are boxed) + /// and that value becomes the Task's result. ///
/// State-machine splitting at await sites is delegated to /// via ; the await handling comes from @@ -34,10 +37,13 @@ namespace Microsoft.Scripting.Ast { public sealed class AsyncExpression : Expression { private Expression? _reduced; - internal AsyncExpression(string? name, Expression body, Expression cancellationToken) { + internal AsyncExpression(string? name, Expression body, + Expression? cancellationToken = null, + Expression? cancellationException = null) { Name = name; Body = body; - CancellationToken = cancellationToken; + CancellationToken = cancellationToken ?? DefaultCancellationToken; + CancellationException = cancellationException ?? DefaultCancellationException; } /// @@ -56,6 +62,15 @@ internal AsyncExpression(string? name, Expression body, Expression cancellationT /// public Expression CancellationToken { get; } + /// + /// Expression evaluating to a StrongBox<Exception?> (null allowed) — see + /// 's cancellationException parameter. When the box is + /// non-null and its Value is non-null at cancellation time, that exception is surfaced to + /// the body instead of . Defaults to a null + /// constant (the plain-cancellation behavior). + /// + public Expression CancellationException { get; } + public override bool CanReduce => true; public override Type Type => typeof(Task); @@ -69,9 +84,16 @@ public override Expression Reduce() { protected override Expression VisitChildren(ExpressionVisitor visitor) { Expression b = visitor.Visit(Body); Expression ct = visitor.Visit(CancellationToken); - if (b == Body && ct == CancellationToken) return this; - return new AsyncExpression(Name, b, ct); + Expression ce = visitor.Visit(CancellationException); + if (b == Body && ct == CancellationToken && ce == CancellationException) return this; + return new AsyncExpression(Name, b, ct, ce); } + + private static Expression DefaultCancellationException + => Expression.Constant(null, typeof(StrongBox)); + + private static Expression DefaultCancellationToken + => Expression.Default(typeof(CancellationToken)); } public partial class Utils { @@ -79,32 +101,46 @@ public partial class Utils { /// Wraps an async-function body in an . /// /// - /// The body may contain suspension points and should evaluate to ; the - /// resulting expression evaluates to Task<object>. Cancellation defaults to default(CancellationToken); use - /// the overload to supply one. + /// The body may contain suspension points and may evaluate to any + /// type (including ): non-void values are converted to + /// — value types are boxed — and become the result of the resulting Task<object>; a + /// void body produces a result. Cancellation defaults to + /// default(CancellationToken). /// public static AsyncExpression Async(string? name, Expression body) { ContractUtils.RequiresNotNull(body, nameof(body)); - return new AsyncExpression(name, body, Expression.Default(typeof(CancellationToken))); + return new AsyncExpression(name, body); } /// - /// Wraps an async-function body in an with a caller-provided . + /// Wraps an async-function body in an with a caller-provided + /// and, optionally, an exception-override box. /// /// - /// The token expression is evaluated once when the body starts and is then sampled by - /// between iterations and at each suspended await. + /// When cancellation fires and the box's Value is non-null, that exception is delivered to + /// the body instead of a fresh . This lets a host inject + /// an arbitrary exception (e.g. Python's coro.throw(exc)) by populating the box and then + /// cancelling the token. defaults to null + /// — the plain OCE-on-cancellation behavior. /// - public static AsyncExpression Async(string? name, Expression body, Expression cancellationToken) { + public static AsyncExpression Async(string? name, Expression body, + Expression cancellationToken, + Expression? cancellationException = null) { ContractUtils.RequiresNotNull(body, nameof(body)); ContractUtils.RequiresNotNull(cancellationToken, nameof(cancellationToken)); - if (cancellationToken.Type != typeof(CancellationToken)) { + RequireType(cancellationToken, typeof(CancellationToken), nameof(cancellationToken)); + if (cancellationException is not null) { + RequireType(cancellationException, typeof(StrongBox), nameof(cancellationException)); + } + return new AsyncExpression(name, body, cancellationToken, cancellationException); + } + + private static void RequireType(Expression expr, Type expected, string paramName) { + if (expr.Type != expected) { throw new ArgumentException( - $"Expression must evaluate to {nameof(CancellationToken)}, got {cancellationToken.Type}.", - nameof(cancellationToken)); + $"Expression must evaluate to {expected.Name}, got {expr.Type}.", + paramName); } - return new AsyncExpression(name, body, cancellationToken); } } } diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs index d813e546..246e1c5a 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs @@ -87,7 +87,8 @@ public Expression Reduce() { generator, valueSlot, exceptionSlot, - _node.CancellationToken); + _node.CancellationToken, + _node.CancellationException); return Expression.Block( typeof(Task), diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs index 79e46d26..5699939a 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs @@ -36,6 +36,14 @@ public static class AsyncHelpers { /// Caller's cancellation token. Sampled at each loop iteration and linked to each /// awaited task; see remarks for the cancellation model. /// + /// + /// Optional override for the exception surfaced when + /// fires. If non-null and its Value is non-null at the moment cancellation is + /// observed, that exception is delivered into instead + /// of a fresh . It lets a host inject an arbitrary + /// exception (e.g. for Python's coro.throw(exc)) by setting the box's value and + /// then cancelling the token. Default null preserves the plain-cancellation behavior. + /// /// /// This method is itself an async Task whose IL is produced by Roslyn (on .NET 11+, under feature /// runtime-async=on), so each await below becomes a real .NET 11+ runtime-async @@ -59,21 +67,24 @@ public static class AsyncHelpers { /// It is made public only for internal use by the DLR. /// [Obsolete("do not call this method directly from source-level code", error: true)] - public static async Task DriveAsync(IEnumerator states, + public static async Task DriveAsync(IEnumerator states, StrongBox valueSlot, StrongBox exceptionSlot, - CancellationToken cancellationToken = default) { + CancellationToken cancellationToken , + StrongBox? cancellationException = null) { while (states.MoveNext()) { - object yielded = states.Current; + object? yielded = states.Current; // Surface cancellation at the just-yielded suspension point so the body's try/except around // `await` can observe it (matches asyncio's "CancelledError raised at the await" model). // Caught here as well as in the WaitAsync branch so that a stretch of synchronously-resolved - // yields is still cancellable. + // yields is still cancellable. If the host pre-populated cancellationException, deliver that + // instead of a fresh OCE — lets coro.throw(arbitrary) inject any exception type. if (cancellationToken.IsCancellationRequested) { valueSlot.Value = null; - exceptionSlot.Value = new OperationCanceledException(cancellationToken); + exceptionSlot.Value = cancellationException?.Value + ?? new OperationCanceledException(cancellationToken); continue; } From fae12c039c035f3d25f028e297baa5080d5665ef Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Sun, 24 May 2026 20:49:24 -0700 Subject: [PATCH 12/14] Support async-enumerable expressions (.NET only) --- .../Ast/AsyncEnumerableExpression.cs | 124 ++++++++++++++++ .../Ast/AsyncEnumerableRewriter.cs | 134 ++++++++++++++++++ .../Microsoft.Dynamic/Ast/AsyncRewriter.cs | 2 +- .../Microsoft.Dynamic/Runtime/AsyncHelpers.cs | 81 ++++++++++- 4 files changed, 336 insertions(+), 5 deletions(-) create mode 100644 src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs create mode 100644 src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs new file mode 100644 index 00000000..6c68e642 --- /dev/null +++ b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs @@ -0,0 +1,124 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +#if NET + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; + +using Microsoft.Scripting.Utils; + +namespace Microsoft.Scripting.Ast { + /// + /// Wraps an async-generator body (one that contains both nodes and + /// nodes targeting ) into an expression that + /// evaluates to of . + /// + /// + /// Await points are rewritten to yield AwaitPoint(task) against the same label as the + /// language-level yields, so a single GeneratorRewriter-produced + /// carries both kinds of items. + /// then awaits + /// items internally and emits the rest to the + /// consumer. This marker is what lets await and yield coexist: a yielded Task is not an + /// AwaitPoint, so it is surfaced as a value rather than awaited. + /// + public sealed class AsyncEnumerableExpression : Expression { + private Expression? _reduced; + + internal AsyncEnumerableExpression(string? name, Expression body, LabelTarget yieldLabel, + Expression? cancellationToken = null, + Expression? cancellationException = null) { + Name = name; + Body = body; + YieldLabel = yieldLabel; + CancellationToken = cancellationToken ?? Expression.Default(typeof(CancellationToken)); + CancellationException = cancellationException ?? Expression.Constant(null, typeof(StrongBox)); + } + + /// Optional diagnostic name (forwarded to the inner generator). + public string? Name { get; } + + /// The generator body. May contain and nodes. + public Expression Body { get; } + + /// + /// The label both the language-level yields and the rewritten awaits target, so they + /// land in one generator. Supplied by the host (e.g. IronPython's shared generator label). + /// + public LabelTarget YieldLabel { get; } + + /// Expression evaluating to the cancellation token (see ). Default default(CancellationToken). + public Expression CancellationToken { get; } + + /// Expression evaluating to a StrongBox<Exception?> exception override (or null). Default null. + public Expression CancellationException { get; } + + public override bool CanReduce => true; + + public override Type Type => typeof(IAsyncEnumerable); + + public override ExpressionType NodeType => ExpressionType.Extension; + + public override Expression Reduce() { + return _reduced ??= new AsyncEnumerableRewriter(this).Reduce(); + } + + protected override Expression VisitChildren(ExpressionVisitor visitor) { + Expression b = visitor.Visit(Body); + Expression ct = visitor.Visit(CancellationToken); + Expression ce = visitor.Visit(CancellationException); + if (b == Body && ct == CancellationToken && ce == CancellationException) return this; + return new AsyncEnumerableExpression(Name, b, YieldLabel, ct, ce); + } + } + + public partial class Utils { + /// + /// Wraps an async-generator body in an producing IAsyncEnumerable<object>. + /// + /// + /// It must be the same label the body's language-level yields target. + /// + public static AsyncEnumerableExpression AsyncEnumerable(string? name, Expression body, LabelTarget yieldLabel) { + ContractUtils.RequiresNotNull(body, nameof(body)); + ContractUtils.RequiresNotNull(yieldLabel, nameof(yieldLabel)); + return new AsyncEnumerableExpression(name, body, yieldLabel); + } + + /// + /// Wraps an async-generator body in an producing IAsyncEnumerable<object>, + /// with a caller-provided and, optionally, an exception-override box. + /// + /// + /// When cancellation fires and the box's Value is non-null, that exception is delivered to + /// the body instead of a fresh . This lets a host inject + /// an arbitrary exception (e.g. Python's coro.throw(exc)) by populating the box and then + /// cancelling the token. defaults to null + /// — the plain OCE-on-cancellation behavior. + /// + /// + /// It must be the same label the body's language-level yields target. + /// + public static AsyncEnumerableExpression AsyncEnumerable(string? name, Expression body, LabelTarget yieldLabel, + Expression cancellationToken, + Expression? cancellationException = null) { + ContractUtils.RequiresNotNull(body, nameof(body)); + ContractUtils.RequiresNotNull(yieldLabel, nameof(yieldLabel)); + RequireType(cancellationToken, typeof(CancellationToken), nameof(cancellationToken)); + if (cancellationException is not null) { + RequireType(cancellationException, typeof(StrongBox), nameof(cancellationException)); + } + return new AsyncEnumerableExpression(name, body, yieldLabel, cancellationToken, cancellationException); + } + } +} + +#endif diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs new file mode 100644 index 00000000..e6e78654 --- /dev/null +++ b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs @@ -0,0 +1,134 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the Apache 2.0 License. +// See the LICENSE file in the project root for more information. + +#nullable enable + +#if NET + +using System; +using System.Collections.Generic; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.ExceptionServices; +using System.Threading.Tasks; + +namespace Microsoft.Scripting.Ast { + /// + /// Reduces an to an IAsyncEnumerable<object>-valued + /// expression tree that yields each 's operand (wrapped in an + /// ) alongside language-level yields, and hands + /// the resulting state machine to . + /// + internal sealed class AsyncEnumerableRewriter { + private static readonly MethodInfo s_driveMethod + = typeof(Microsoft.Scripting.Runtime.AsyncHelpers).GetMethod("DriveAsyncEnumerable")!; + private static readonly ConstructorInfo s_awaitPointCtor + = typeof(Microsoft.Scripting.Runtime.AwaitPoint).GetConstructor([typeof(Task)])!; + private static readonly FieldInfo s_valueSlotField + = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; + private static readonly FieldInfo s_exceptionSlotField + = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; + private static readonly ConstructorInfo s_valueSlotCtor + = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + private static readonly ConstructorInfo s_exceptionSlotCtor + = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + + private readonly AsyncEnumerableExpression _node; + + public AsyncEnumerableRewriter(AsyncEnumerableExpression node) { + _node = node; + } + + public Expression Reduce() { + // valueSlot / exceptionSlot carry the per-await result / fault back into the body at each + // await's resume point (same role as in AsyncRewriter). The generator's final value is + // irrelevant — generators don't return a value — so there is no capture step here. + ParameterExpression valueSlot = Expression.Variable(typeof(StrongBox), "$asyncValue"); + ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); + + var rewriter = new AwaitToAwaitPointRewriter(_node.YieldLabel, valueSlot, exceptionSlot); + Expression rewrittenBody = rewriter.Visit(_node.Body); + + // Coerce to void for Utils.Generator (the generator body's value is discarded). + Expression generatorBody = rewrittenBody.Type == typeof(void) + ? rewrittenBody + : Expression.Block(typeof(void), rewrittenBody); + + Expression generator = Utils.Generator( + _node.Name ?? "$asyncgen", + _node.YieldLabel, + generatorBody, + typeof(IEnumerator), + rewriteAssignments: false); + + // Argument order matches DriveAsyncEnumerable: ..., cancellationToken, cancellationException + // (same as DriveAsync — cancellationToken is the last required parameter). + Expression drive = Expression.Call( + s_driveMethod, + generator, + valueSlot, + exceptionSlot, + _node.CancellationToken, + _node.CancellationException); + + return Expression.Block( + typeof(IAsyncEnumerable), + [valueSlot, exceptionSlot], + Expression.Assign(valueSlot, Expression.New(s_valueSlotCtor)), + Expression.Assign(exceptionSlot, Expression.New(s_exceptionSlotCtor)), + drive); + } + + /// + /// Rewrites AwaitExpression(task){ yield AwaitPoint(task); rethrow-if-pending; valueSlot.Value }, + /// targeting the shared yield label. Mirrors AsyncRewriter.AwaitToYieldRewriter but wraps the awaited + /// Task in an so the driver distinguishes it from a + /// value yielded by a language-level yield. + /// + private sealed class AwaitToAwaitPointRewriter : ExpressionVisitor { + private static readonly MethodInfo s_captureMethod + = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Capture))!; + private static readonly MethodInfo s_throwMethod + = typeof(ExceptionDispatchInfo).GetMethod(nameof(ExceptionDispatchInfo.Throw), Type.EmptyTypes)!; + + private readonly LabelTarget _yieldLabel; + private readonly ParameterExpression _valueSlot; + private readonly ParameterExpression _exceptionSlot; + + public AwaitToAwaitPointRewriter(LabelTarget yieldLabel, ParameterExpression valueSlot, ParameterExpression exceptionSlot) { + _yieldLabel = yieldLabel; + _valueSlot = valueSlot; + _exceptionSlot = exceptionSlot; + } + + protected override Expression VisitExtension(Expression node) { + if (node is AwaitExpression aw) { + Expression operand = Visit(aw.Operand); + // Wrap the awaited Task in an AwaitPoint marker, then box to object for the yield. + Expression awaitPoint = Expression.New(s_awaitPointCtor, Expression.Convert(operand, typeof(Task))); + Expression yielded = Expression.Convert(awaitPoint, typeof(object)); + + Expression readException = Expression.Field(_exceptionSlot, s_exceptionSlotField); + Expression readSlot = Expression.Field(_valueSlot, s_valueSlotField); + + Expression rethrow = Expression.IfThen( + Expression.ReferenceNotEqual(readException, Expression.Constant(null, typeof(Exception))), + Expression.Call( + Expression.Call(s_captureMethod, readException), + s_throwMethod)); + + return Expression.Block( + typeof(object), + Utils.YieldReturn(_yieldLabel, yielded), + rethrow, + readSlot); + } + return base.VisitExtension(node); + } + } + } +} + +#endif diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs index 246e1c5a..5ee42e7c 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs @@ -92,7 +92,7 @@ public Expression Reduce() { return Expression.Block( typeof(Task), - [ valueSlot, exceptionSlot ], + [valueSlot, exceptionSlot], Expression.Assign(valueSlot, Expression.New(s_valueSlotCtor)), Expression.Assign(exceptionSlot, Expression.New(s_exceptionSlotCtor)), drive); diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs index 5699939a..bbc96760 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs @@ -62,7 +62,7 @@ public static class AsyncHelpers { /// the body's next resume point — exactly the place a body-level try/except around the await expects to observe it. /// If the body lets the exception propagate, it bubbles out of and the returned Task transitions to because the OCE's token matches. - /// + /// /// This method is not obsolete but is not part of the public API surface; do not call it directly from source-level code. /// It is made public only for internal use by the DLR. /// @@ -74,7 +74,7 @@ public static class AsyncHelpers { StrongBox? cancellationException = null) { while (states.MoveNext()) { - object? yielded = states.Current; + object? item = states.Current; // Surface cancellation at the just-yielded suspension point so the body's try/except around // `await` can observe it (matches asyncio's "CancelledError raised at the await" model). @@ -88,7 +88,7 @@ public static class AsyncHelpers { continue; } - if (yielded is Task task) { + if (item is Task task) { try { // Task.WaitAsync is BCL on net6+, polyfilled by Meziantou.Polyfill on net4x/netstandard2.0. // No ConfigureAwait(false): honor caller's SyncContext / TaskScheduler. @@ -103,7 +103,7 @@ public static class AsyncHelpers { } } else { // Synchronously-produced value forwards straight through. - valueSlot.Value = yielded; + valueSlot.Value = item; exceptionSlot.Value = null; } } @@ -114,6 +114,68 @@ public static class AsyncHelpers { } +#if NET + /// + /// Drives a language async-generator body, producing an of object. + /// The async-enumerable version of . + /// + /// + /// The body's state machine (an from GeneratorRewriter) yields two + /// kinds of items, discriminated by type: + /// + /// — an internal suspension point. The driver awaits its Task and feeds + /// the result back through (faults/cancellation through + /// ), exactly like . Not emitted to the consumer. + /// + /// anything else — a value produced by a language-level yield; emitted to the consumer via + /// yield return. A yielded Task is NOT an , so it is surfaced as a value, + /// never awaited — this is the disambiguation that lets await and yield coexist. + /// + /// + /// This method is not obsolete but is not part of the public API surface; do not call it directly from source-level code. + /// It is made public only for internal use by the DLR. + /// + [Obsolete("do not call this method directly from source-level code", error: true)] + public static async IAsyncEnumerable DriveAsyncEnumerable(IEnumerator states, + StrongBox valueSlot, + StrongBox exceptionSlot, + [EnumeratorCancellation] CancellationToken cancellationToken, + StrongBox? cancellationException = null) { + + while (states.MoveNext()) { + object? item = states.Current; + + if (cancellationToken.IsCancellationRequested) { + valueSlot.Value = null; + exceptionSlot.Value = cancellationException?.Value + ?? new OperationCanceledException(cancellationToken); + continue; + } + + if (item is AwaitPoint awaitPoint) { + // Suspension point — await internally, do not surface to the consumer. + try { + await awaitPoint.Task.WaitAsync(cancellationToken); + valueSlot.Value = ExtractTaskResult(awaitPoint.Task); + exceptionSlot.Value = null; + } catch (Exception ex) { + valueSlot.Value = null; + exceptionSlot.Value = ex; + } + } else { + // Value from a language-level `yield` — emit it. + yield return item; + + // No value flows back in for a plain `async for`; + // Clear the slots so the body's resume of the yield expression observes null. + valueSlot.Value = null; + exceptionSlot.Value = null; + } + } + } +#endif + + private static object? ExtractTaskResult(Task task) { // Fast-track for the most common case (e.g. IronPython): all awaitables are normalized to Task. if (task is Task to) return to.Result; @@ -139,4 +201,15 @@ public static class AsyncHelpers { return prop.GetValue(task); } } + + + /// + /// Marker wrapping a to flag an await-suspension point inside an async-generator body, + /// distinguishing it from a value produced by a language-level yield. Emitted by + /// 's await rewrite and consumed by + /// . + /// + internal sealed class AwaitPoint(Task task) { + public Task Task { get; } = task; + } } From ca39977c40a20decb5ff1b2c14a450e29ca4ebaa Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Tue, 26 May 2026 13:51:35 -0700 Subject: [PATCH 13/14] Update after review --- .../Ast/AsyncEnumerableExpression.cs | 8 +++---- .../Ast/AsyncEnumerableRewriter.cs | 8 +++---- .../Microsoft.Dynamic/Ast/AsyncExpression.cs | 22 ++++--------------- .../Microsoft.Dynamic/Ast/AsyncRewriter.cs | 4 ++-- src/core/Microsoft.Dynamic/Ast/Utils.cs | 12 ++++++++-- .../Microsoft.Dynamic/Runtime/AsyncHelpers.cs | 3 +++ .../Microsoft.Dynamic/Utils/ContractUtils.cs | 13 +++++++++++ 7 files changed, 40 insertions(+), 30 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs index 6c68e642..6c2377cf 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs @@ -39,8 +39,8 @@ internal AsyncEnumerableExpression(string? name, Expression body, LabelTarget yi Name = name; Body = body; YieldLabel = yieldLabel; - CancellationToken = cancellationToken ?? Expression.Default(typeof(CancellationToken)); - CancellationException = cancellationException ?? Expression.Constant(null, typeof(StrongBox)); + CancellationToken = cancellationToken ?? Utils.DefaultCancellationToken; + CancellationException = cancellationException ?? Utils.DefaultCancellationException; } /// Optional diagnostic name (forwarded to the inner generator). @@ -112,9 +112,9 @@ public static AsyncEnumerableExpression AsyncEnumerable(string? name, Expression Expression? cancellationException = null) { ContractUtils.RequiresNotNull(body, nameof(body)); ContractUtils.RequiresNotNull(yieldLabel, nameof(yieldLabel)); - RequireType(cancellationToken, typeof(CancellationToken), nameof(cancellationToken)); + ContractUtils.RequiresType(cancellationToken, typeof(CancellationToken), nameof(cancellationToken)); if (cancellationException is not null) { - RequireType(cancellationException, typeof(StrongBox), nameof(cancellationException)); + ContractUtils.RequiresType(cancellationException, typeof(StrongBox), nameof(cancellationException)); } return new AsyncEnumerableExpression(name, body, yieldLabel, cancellationToken, cancellationException); } diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs index e6e78654..14a78e50 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs @@ -18,14 +18,14 @@ namespace Microsoft.Scripting.Ast { /// /// Reduces an to an IAsyncEnumerable<object>-valued /// expression tree that yields each 's operand (wrapped in an - /// ) alongside language-level yields, and hands - /// the resulting state machine to . + /// ) alongside language-level yields, and hands + /// the resulting state machine to . /// internal sealed class AsyncEnumerableRewriter { private static readonly MethodInfo s_driveMethod - = typeof(Microsoft.Scripting.Runtime.AsyncHelpers).GetMethod("DriveAsyncEnumerable")!; + = typeof(Runtime.AsyncHelpers).GetMethod(nameof(Runtime.AsyncHelpers.DriveAsyncEnumerable))!; private static readonly ConstructorInfo s_awaitPointCtor - = typeof(Microsoft.Scripting.Runtime.AwaitPoint).GetConstructor([typeof(Task)])!; + = typeof(Runtime.AwaitPoint).GetConstructor([typeof(Task)])!; private static readonly FieldInfo s_valueSlotField = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; private static readonly FieldInfo s_exceptionSlotField diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index 8047a11c..49edab19 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -42,8 +42,8 @@ internal AsyncExpression(string? name, Expression body, Expression? cancellationException = null) { Name = name; Body = body; - CancellationToken = cancellationToken ?? DefaultCancellationToken; - CancellationException = cancellationException ?? DefaultCancellationException; + CancellationToken = cancellationToken ?? Utils.DefaultCancellationToken; + CancellationException = cancellationException ?? Utils.DefaultCancellationException; } /// @@ -88,12 +88,6 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { if (b == Body && ct == CancellationToken && ce == CancellationException) return this; return new AsyncExpression(Name, b, ct, ce); } - - private static Expression DefaultCancellationException - => Expression.Constant(null, typeof(StrongBox)); - - private static Expression DefaultCancellationToken - => Expression.Default(typeof(CancellationToken)); } public partial class Utils { @@ -128,19 +122,11 @@ public static AsyncExpression Async(string? name, Expression body, Expression? cancellationException = null) { ContractUtils.RequiresNotNull(body, nameof(body)); ContractUtils.RequiresNotNull(cancellationToken, nameof(cancellationToken)); - RequireType(cancellationToken, typeof(CancellationToken), nameof(cancellationToken)); + ContractUtils.RequiresType(cancellationToken, typeof(CancellationToken), nameof(cancellationToken)); if (cancellationException is not null) { - RequireType(cancellationException, typeof(StrongBox), nameof(cancellationException)); + ContractUtils.RequiresType(cancellationException, typeof(StrongBox), nameof(cancellationException)); } return new AsyncExpression(name, body, cancellationToken, cancellationException); } - - private static void RequireType(Expression expr, Type expected, string paramName) { - if (expr.Type != expected) { - throw new ArgumentException( - $"Expression must evaluate to {expected.Name}, got {expr.Type}.", - paramName); - } - } } } diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs index 5ee42e7c..ee52b13f 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs @@ -16,11 +16,11 @@ namespace Microsoft.Scripting.Ast { /// /// Reduces an to a Task<object>-valued /// expression tree that yields each 's operand and - /// hands the resulting state machine to . + /// hands the resulting state machine to . /// internal sealed class AsyncRewriter { private static readonly MethodInfo s_driveMethod - = typeof(Microsoft.Scripting.Runtime.AsyncHelpers).GetMethod("DriveAsync")!; + = typeof(Runtime.AsyncHelpers).GetMethod(nameof(Runtime.AsyncHelpers.DriveAsync))!; private static readonly FieldInfo s_valueSlotField = typeof(StrongBox).GetField(nameof(StrongBox.Value))!; private static readonly FieldInfo s_exceptionSlotField diff --git a/src/core/Microsoft.Dynamic/Ast/Utils.cs b/src/core/Microsoft.Dynamic/Ast/Utils.cs index 76c3b04f..1c27d4d3 100644 --- a/src/core/Microsoft.Dynamic/Ast/Utils.cs +++ b/src/core/Microsoft.Dynamic/Ast/Utils.cs @@ -2,11 +2,13 @@ // The .NET Foundation licenses this file to you under the Apache 2.0 License. // See the LICENSE file in the project root for more information. -using System.Linq.Expressions; using System; -using System.Reflection; using System.Dynamic; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Threading; using Microsoft.Scripting.Utils; using AstUtils = Microsoft.Scripting.Ast.Utils; @@ -92,5 +94,11 @@ public static bool IsLValue(this ExpressionType type) { return false; } + + internal static Expression DefaultCancellationException + => Expression.Constant(null, typeof(StrongBox)); + + internal static Expression DefaultCancellationToken + => Expression.Default(typeof(CancellationToken)); } } diff --git a/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs index bbc96760..b084828d 100644 --- a/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs +++ b/src/core/Microsoft.Dynamic/Runtime/AsyncHelpers.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; +using System.ComponentModel; using System.Runtime.CompilerServices; using System.Runtime.ExceptionServices; using System.Threading; @@ -67,6 +68,7 @@ public static class AsyncHelpers { /// It is made public only for internal use by the DLR. /// [Obsolete("do not call this method directly from source-level code", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] public static async Task DriveAsync(IEnumerator states, StrongBox valueSlot, StrongBox exceptionSlot, @@ -136,6 +138,7 @@ public static class AsyncHelpers { /// It is made public only for internal use by the DLR. /// [Obsolete("do not call this method directly from source-level code", error: true)] + [EditorBrowsable(EditorBrowsableState.Never)] public static async IAsyncEnumerable DriveAsyncEnumerable(IEnumerator states, StrongBox valueSlot, StrongBox exceptionSlot, diff --git a/src/core/Microsoft.Dynamic/Utils/ContractUtils.cs b/src/core/Microsoft.Dynamic/Utils/ContractUtils.cs index 3a409440..f15f2f82 100644 --- a/src/core/Microsoft.Dynamic/Utils/ContractUtils.cs +++ b/src/core/Microsoft.Dynamic/Utils/ContractUtils.cs @@ -182,6 +182,19 @@ public static void RequiresNotNullItems(IEnumerable collection, string col } } + + /// + /// Requires the expression to evaluate to the expected type. + /// + internal static void RequiresType(Expression expr, Type expected, string paramName) { + if (expr.Type != expected) { + throw new ArgumentException( + $"Expression must evaluate to {expected.Name}, got {expr.Type}.", + paramName); + } + } + + [Conditional("FALSE")] public static void Invariant(bool condition) { Debug.Assert(condition); From 071e57b0726206e6ceb3bbc013901f43752c93e7 Mon Sep 17 00:00:00 2001 From: Pavel Koneski Date: Tue, 26 May 2026 15:18:24 -0700 Subject: [PATCH 14/14] Cache static fields for async default arguments --- src/core/Microsoft.Dynamic/Ast/Utils.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/Microsoft.Dynamic/Ast/Utils.cs b/src/core/Microsoft.Dynamic/Ast/Utils.cs index 1c27d4d3..674b6e2f 100644 --- a/src/core/Microsoft.Dynamic/Ast/Utils.cs +++ b/src/core/Microsoft.Dynamic/Ast/Utils.cs @@ -96,9 +96,9 @@ public static bool IsLValue(this ExpressionType type) { } internal static Expression DefaultCancellationException - => Expression.Constant(null, typeof(StrongBox)); + => field ??= Expression.Constant(null, typeof(StrongBox)); internal static Expression DefaultCancellationToken - => Expression.Default(typeof(CancellationToken)); + => field ??= Expression.Default(typeof(CancellationToken)); } }