diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncDebugSupport.cs b/src/core/Microsoft.Dynamic/Ast/AsyncDebugSupport.cs new file mode 100644 index 00000000..396583c8 --- /dev/null +++ b/src/core/Microsoft.Dynamic/Ast/AsyncDebugSupport.cs @@ -0,0 +1,213 @@ +// 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.Globalization; +using System.Linq.Expressions; +using System.Reflection; +using System.Runtime.CompilerServices; + +using Microsoft.Scripting.Debugging; +using Microsoft.Scripting.Debugging.CompilerServices; + +namespace Microsoft.Scripting.Ast { + /// + /// Compiles an async body for tracing. + /// + /// + /// Used by and AsyncEnumerableRewriter when the async node carries a debug hook (set by the host + /// only when it builds the tracing variant of the code, e.g. when sys.settrace is active). In two steps: + /// + /// instruments the body before it is sliced into a state machine, so that the debug points + /// end up in the same state machine as the awaits and yields. + /// wraps the resulting state machine in a , which dispatches + /// the trace events while the driver pumps it. + /// + /// + /// The body's locals are not lifted, so its frame reports its location but no locals; the scope map is therefore uniform + /// (every sequence point maps to no locals). + /// + internal sealed class AsyncDebugSupport { + private static readonly ConstructorInfo s_tracingStateMachineCtor + = typeof(TracingStateMachine).GetConstructor( + BindingFlags.Instance | BindingFlags.NonPublic, binder: null, + [typeof(IEnumerator), typeof(DebugContext), typeof(FunctionInfo), typeof(StrongBox), typeof(StrongBox), typeof(bool)], + modifiers: null)!; + private static readonly MethodInfo s_onExceptionCaughtMethod + = typeof(TracingStateMachine).GetMethod(nameof(TracingStateMachine.OnExceptionCaught), BindingFlags.Instance | BindingFlags.NonPublic)!; + + private readonly DebugContext _debugContext; + private readonly DebugLambdaInfo _lambdaInfo; + private readonly string? _name; + private readonly ParameterExpression _exceptionSlot; + + // Holds the TracingStateMachine, so that the body's exception handlers can report to it. + private readonly ParameterExpression _tracer = Expression.Variable(typeof(TracingStateMachine), "$tracer"); + + // Null until the body is instrumented, and also if it has no sequence points (then there is nothing to trace). + private FunctionInfo? _functionInfo; + + /// The body's exception slot, which the body rethrows from at each resume point. + internal AsyncDebugSupport(DebugContext debugContext, DebugLambdaInfo lambdaInfo, string? name, ParameterExpression exceptionSlot) { + _debugContext = debugContext; + _lambdaInfo = lambdaInfo; + _name = name; + _exceptionSlot = exceptionSlot; + } + + + /// + /// Instruments to report its sequence points and the exceptions it handles. + /// + /// + /// Each becomes { yield DebugPoint(cookie); rethrow-if-pending } on + /// , the label the body's awaits and yields are sliced on, and each exception handler first reports + /// the exception it handles. The rethrow lets an exception raised by the trace callback surface inside the body. + /// + internal Expression InjectDebugPoints(Expression body, LabelTarget yieldLabel) { + var injector = new DebugPointInjector(_debugContext, yieldLabel, _exceptionSlot, _tracer); + Expression instrumentedBody = injector.Visit(body); + if (injector.SequencePoints.Length == 0) { + return body; + } + + _functionInfo = DebugContext.CreateFunctionInfo( + generatorFactory: null, // the frame cannot be remapped to a generator, so it cannot change its location either + _name ?? "", + injector.SequencePoints, + injector.VariableScopeMap, + Array.Empty(), + _lambdaInfo.CustomPayload); + return instrumentedBody; + } + + + /// + /// Wraps , the state machine of the instrumented body, in a . + /// + /// Where the body leaves its return value, or null if it has none. + /// Whether the state machine is driven by AsyncHelpers.DriveAsyncEnumerable. + internal Expression TraceStateMachine(Expression stateMachine, ParameterExpression? resultSlot, bool isAsyncEnumerable) { + if (_functionInfo is null) { + return stateMachine; + } + + return Expression.Block( + typeof(TracingStateMachine), + [_tracer], + Expression.Assign( + _tracer, + Expression.New( + s_tracingStateMachineCtor, + stateMachine, + Expression.Constant(_debugContext), + Expression.Constant(_functionInfo), + (Expression?)resultSlot ?? Expression.Constant(null, typeof(StrongBox)), + _exceptionSlot, + Expression.Constant(isAsyncEnumerable)))); + } + + + /// + /// Rewrites the debug info and the exception handlers of an async body for . + /// + /// + /// Each becomes a yield of a on the body's own yield label, recording + /// the source span per cookie, and each catch block first reports the exception to the tracer. This mirrors what + /// DebugInfoRewriter does to a synchronous body. + /// + private sealed class DebugPointInjector : ExpressionVisitor { + private readonly DebugContext _debugContext; + private readonly LabelTarget _yieldLabel; + private readonly ParameterExpression _exceptionSlot; + private readonly ParameterExpression _tracer; + private readonly List _spans = new(); + + internal DebugPointInjector(DebugContext debugContext, LabelTarget yieldLabel, ParameterExpression exceptionSlot, ParameterExpression tracer) { + _debugContext = debugContext; + _yieldLabel = yieldLabel; + _exceptionSlot = exceptionSlot; + _tracer = tracer; + } + + internal DebugSourceSpan[] SequencePoints => _spans.ToArray(); + + internal IList[] VariableScopeMap { + get { + var map = new IList[_spans.Count]; + for (int i = 0; i < map.Length; i++) { + map[i] = Array.Empty(); + } + return map; + } + } + + protected override Expression VisitDebugInfo(DebugInfoExpression node) { + if (node.IsClear) { + return Expression.Empty(); + } + + if (node.Document is null) { + throw new InvalidOperationException( + string.Format(CultureInfo.CurrentCulture, "DebugInfoExpression at sequence point {0} has no SymbolDocumentInfo", _spans.Count)); + } + + DebugSourceFile sourceFile = _debugContext.GetDebugSourceFile( + string.IsNullOrEmpty(node.Document.FileName) ? "" : node.Document.FileName); + + int cookie = _spans.Count; + _spans.Add(new DebugSourceSpan(sourceFile, node.StartLine, node.StartColumn, node.EndLine, node.EndColumn)); + + return Expression.Block( + typeof(void), + Utils.YieldReturn(_yieldLabel, Expression.Constant(new DebugPoint(cookie))), + AsyncRewriter.RethrowIfPending(_exceptionSlot)); + } + + protected override Expression VisitTry(TryExpression node) { + var visited = (TryExpression)base.VisitTry(node); + if (visited.Handlers.Count == 0) { + return visited; + } + + var handlers = new List(visited.Handlers.Count); + foreach (CatchBlock handler in visited.Handlers) { + ParameterExpression variable = handler.Variable ?? Expression.Variable(handler.Test, null); + Expression exception = variable.Type == typeof(Exception) ? variable : Expression.Convert(variable, typeof(Exception)); + handlers.Add(Expression.MakeCatchBlock( + handler.Test, + variable, + Expression.Block(Expression.Call(_tracer, s_onExceptionCaughtMethod, exception), handler.Body), + handler.Filter)); + } + return Expression.MakeTry(visited.Type, visited.Body, visited.Finally, visited.Fault, handlers); + } + + protected override Expression VisitLambda(Expression node) { + // Don't descend into nested lambdas — they have their own debug context. + return node; + } + + protected override Expression VisitExtension(Expression node) { + // A nested async node is sliced into its own state machine against its own label; leave it opaque. + if (node is AsyncExpression or GeneratorExpression +#if NET + or AsyncEnumerableExpression +#endif + ) { + return node; + } + if (node is YieldExpression { Value: not null } yield && yield.Target == _yieldLabel) { + return Expression.Block( + base.VisitExtension(node), + AsyncRewriter.RethrowIfPending(_exceptionSlot)); + } + return base.VisitExtension(node); + } + } + } +} diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs index 6c2377cf..fd5b60e7 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableExpression.cs @@ -13,6 +13,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Scripting.Debugging.CompilerServices; using Microsoft.Scripting.Utils; namespace Microsoft.Scripting.Ast { @@ -35,12 +36,33 @@ public sealed class AsyncEnumerableExpression : Expression { internal AsyncEnumerableExpression(string? name, Expression body, LabelTarget yieldLabel, Expression? cancellationToken = null, - Expression? cancellationException = null) { + Expression? cancellationException = null, + DebugContext? debugContext = null, + DebugLambdaInfo? debugLambdaInfo = null) { Name = name; Body = body; YieldLabel = yieldLabel; CancellationToken = cancellationToken ?? Utils.DefaultCancellationToken; CancellationException = cancellationException ?? Utils.DefaultCancellationException; + DebugContext = debugContext; + DebugLambdaInfo = debugLambdaInfo; + } + + /// + /// Optional tracing hook; see . When both are non-null, + /// instruments the body with debug points so it becomes traceable. + /// + internal DebugContext? DebugContext { get; } + + internal DebugLambdaInfo? DebugLambdaInfo { get; } + + /// + /// Returns a copy of this node that instruments its body for tracing under the given debug context. + /// + public AsyncEnumerableExpression WithDebugInfo(DebugContext debugContext, DebugLambdaInfo debugLambdaInfo) { + ContractUtils.RequiresNotNull(debugContext, nameof(debugContext)); + ContractUtils.RequiresNotNull(debugLambdaInfo, nameof(debugLambdaInfo)); + return new AsyncEnumerableExpression(Name, Body, YieldLabel, CancellationToken, CancellationException, debugContext, debugLambdaInfo); } /// Optional diagnostic name (forwarded to the inner generator). @@ -76,7 +98,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { 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); + return new AsyncEnumerableExpression(Name, b, YieldLabel, ct, ce, DebugContext, DebugLambdaInfo); } } diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs index 14a78e50..27a3e012 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncEnumerableRewriter.cs @@ -11,7 +11,6 @@ using System.Linq.Expressions; using System.Reflection; using System.Runtime.CompilerServices; -using System.Runtime.ExceptionServices; using System.Threading.Tasks; namespace Microsoft.Scripting.Ast { @@ -28,8 +27,6 @@ private static readonly ConstructorInfo s_awaitPointCtor = typeof(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 @@ -48,8 +45,17 @@ public Expression Reduce() { ParameterExpression valueSlot = Expression.Variable(typeof(StrongBox), "$asyncValue"); ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); + // Optional tracing, as in AsyncRewriter: the debug points are injected on the same label the body's yields and awaits target, + // so they all land in one state machine, which is then wrapped in a TracingStateMachine. + Expression body = _node.Body; + AsyncDebugSupport? debugSupport = null; + if (_node.DebugContext is not null && _node.DebugLambdaInfo is not null) { + debugSupport = new AsyncDebugSupport(_node.DebugContext, _node.DebugLambdaInfo, _node.Name, exceptionSlot); + body = debugSupport.InjectDebugPoints(body, _node.YieldLabel); + } + var rewriter = new AwaitToAwaitPointRewriter(_node.YieldLabel, valueSlot, exceptionSlot); - Expression rewrittenBody = rewriter.Visit(_node.Body); + Expression rewrittenBody = rewriter.Visit(body); // Coerce to void for Utils.Generator (the generator body's value is discarded). Expression generatorBody = rewrittenBody.Type == typeof(void) @@ -62,12 +68,13 @@ public Expression Reduce() { generatorBody, typeof(IEnumerator), rewriteAssignments: false); + Expression states = debugSupport?.TraceStateMachine(generator, resultSlot: null, isAsyncEnumerable: true) ?? generator; // Argument order matches DriveAsyncEnumerable: ..., cancellationToken, cancellationException // (same as DriveAsync — cancellationToken is the last required parameter). Expression drive = Expression.Call( s_driveMethod, - generator, + states, valueSlot, exceptionSlot, _node.CancellationToken, @@ -88,11 +95,6 @@ public Expression Reduce() { /// 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; @@ -109,20 +111,12 @@ protected override Expression VisitExtension(Expression node) { // 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, + AsyncRewriter.RethrowIfPending(_exceptionSlot), readSlot); } return base.VisitExtension(node); diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs index 49edab19..2dc0b309 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncExpression.cs @@ -10,6 +10,7 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.Scripting.Debugging.CompilerServices; using Microsoft.Scripting.Runtime; using Microsoft.Scripting.Utils; @@ -39,11 +40,33 @@ public sealed class AsyncExpression : Expression { internal AsyncExpression(string? name, Expression body, Expression? cancellationToken = null, - Expression? cancellationException = null) { + Expression? cancellationException = null, + DebugContext? debugContext = null, + DebugLambdaInfo? debugLambdaInfo = null) { Name = name; Body = body; CancellationToken = cancellationToken ?? Utils.DefaultCancellationToken; CancellationException = cancellationException ?? Utils.DefaultCancellationException; + DebugContext = debugContext; + DebugLambdaInfo = debugLambdaInfo; + } + + /// + /// Optional tracing hook. When both are non-null, instruments the body with debug + /// points so it becomes traceable (see AsyncDebugSupport); when null, reduction is unchanged. + /// Set by the host (e.g. IronPython's FunctionCode) only when building the tracing variant. + /// + internal DebugContext? DebugContext { get; } + + internal DebugLambdaInfo? DebugLambdaInfo { get; } + + /// + /// Returns a copy of this node that instruments its body for tracing under the given debug context. + /// + public AsyncExpression WithDebugInfo(DebugContext debugContext, DebugLambdaInfo debugLambdaInfo) { + ContractUtils.RequiresNotNull(debugContext, nameof(debugContext)); + ContractUtils.RequiresNotNull(debugLambdaInfo, nameof(debugLambdaInfo)); + return new AsyncExpression(Name, Body, CancellationToken, CancellationException, debugContext, debugLambdaInfo); } /// @@ -86,7 +109,7 @@ protected override Expression VisitChildren(ExpressionVisitor visitor) { Expression ct = visitor.Visit(CancellationToken); Expression ce = visitor.Visit(CancellationException); if (b == Body && ct == CancellationToken && ce == CancellationException) return this; - return new AsyncExpression(Name, b, ct, ce); + return new AsyncExpression(Name, b, ct, ce, DebugContext, DebugLambdaInfo); } } diff --git a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs index ee52b13f..7c15a416 100644 --- a/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/AsyncRewriter.cs @@ -29,6 +29,10 @@ private static readonly ConstructorInfo s_valueSlotCtor = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; private static readonly ConstructorInfo s_exceptionSlotCtor = typeof(StrongBox).GetConstructor(Type.EmptyTypes)!; + 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 AsyncExpression _node; @@ -52,9 +56,18 @@ public Expression Reduce() { ParameterExpression exceptionSlot = Expression.Variable(typeof(StrongBox), "$awaitException"); LabelTarget yieldLabel = Expression.Label(typeof(object), "$asyncYield"); + // Optional tracing, when the node carries a debug hook: instrument the body before the await rewrite, so that its debug points + // are sliced into the same state machine as the awaits, and wrap that state machine in a TracingStateMachine (see below). + Expression body = _node.Body; + AsyncDebugSupport? debugSupport = null; + if (_node.DebugContext is not null && _node.DebugLambdaInfo is not null) { + debugSupport = new AsyncDebugSupport(_node.DebugContext, _node.DebugLambdaInfo, _node.Name, exceptionSlot); + body = debugSupport.InjectDebugPoints(body, yieldLabel); + } + // Rewrite AwaitExpression(e) -> { yield e; rethrow-if-pending; valueSlot.Value } var rewriter = new AwaitToYieldRewriter(yieldLabel, valueSlot, exceptionSlot); - Expression rewrittenBody = rewriter.Visit(_node.Body); + Expression rewrittenBody = rewriter.Visit(body); // 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 @@ -81,10 +94,11 @@ public Expression Reduce() { generatorBody, typeof(IEnumerator), rewriteAssignments: false); + Expression states = debugSupport?.TraceStateMachine(generator, resultSlot: valueSlot, isAsyncEnumerable: false) ?? generator; Expression drive = Expression.Call( s_driveMethod, - generator, + states, valueSlot, exceptionSlot, _node.CancellationToken, @@ -98,12 +112,25 @@ public Expression Reduce() { 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)!; + /// + /// Rethrows the exception parked in , if any, preserving its original stack trace. + /// + /// + /// Emitted right after each yield of an async body: whatever resumes the body (the driver past an await, or the tracer past a + /// debug point) parks an exception in the slot to make the body observe it at the resume point. + /// + internal static Expression RethrowIfPending(Expression exceptionSlot) { + Expression readException = Expression.Field(exceptionSlot, s_exceptionSlotField); + return Expression.IfThen( + Expression.ReferenceNotEqual(readException, Expression.Constant(null, typeof(Exception))), + Expression.Call( + Expression.Call(s_captureMethod, readException), + s_throwMethod)); + } + + + private sealed class AwaitToYieldRewriter : ExpressionVisitor { private readonly LabelTarget _yieldLabel; private readonly ParameterExpression _resultSlot; private readonly ParameterExpression _exceptionSlot; @@ -118,22 +145,15 @@ 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 + // 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, + RethrowIfPending(_exceptionSlot), readSlot); } return base.VisitExtension(node); diff --git a/src/core/Microsoft.Dynamic/Ast/GeneratorRewriter.cs b/src/core/Microsoft.Dynamic/Ast/GeneratorRewriter.cs index 013aab28..6358ee23 100644 --- a/src/core/Microsoft.Dynamic/Ast/GeneratorRewriter.cs +++ b/src/core/Microsoft.Dynamic/Ast/GeneratorRewriter.cs @@ -324,6 +324,11 @@ protected override Expression VisitTry(TryExpression node) { if (catchYields != tryYields) { var block = new List(); + // A deferred handler runs if its variable is set, so the variables must be reset on each entry into the try: they are + // hoisted, and would otherwise keep the exception of an earlier pass (e.g. of a loop) whose handler did not reach its end. + // A resumption into a deferred handler is routed past the reset, which keeps the exception for the rest of the handler. + var resetDeferredVars = new List(); + block.Add(MakeYieldRouter(node.Body.Type, tryYields, catchYields, tryStart)); block.Add(null); // empty slot to fill in later @@ -347,6 +352,7 @@ protected override Expression VisitTry(TryExpression node) { // the catch might contain yields. var deferredVar = c.Variable ?? Expression.Variable(c.Test, null); _vars.Add(deferredVar); + resetDeferredVars.Add(Expression.Assign(deferredVar, AstUtils.Constant(null, deferredVar.Type))); // We need to ensure that filters can access the exception // variable @@ -382,7 +388,8 @@ protected override Expression VisitTry(TryExpression node) { ); } - block[1] = Expression.MakeTry(null, @try, null, null, new ReadOnlyCollection(handlers)); + resetDeferredVars.Add(Expression.MakeTry(null, @try, null, null, new ReadOnlyCollection(handlers))); + block[1] = Expression.Block(resetDeferredVars); @try = Expression.Block(block); handlers = EmptyArray.Instance; // so we don't reuse these } @@ -535,7 +542,8 @@ protected override Expression VisitExtension(Expression node) { // Lowered async nodes (AsyncExpression / AsyncEnumerableExpression) carry their own generator // label and are reduced — with their await/yield points rewritten — in their own context so skip here. - // TODO: tracing *into* async bodies will instead require dedicated support here rather than this skip. + // Tracing into an async body does not need this rewriter either: the node instruments its own body when + // reduced, if it carries a debug hook (AsyncExpression.WithDebugInfo). if (node is AsyncExpression #if NET or AsyncEnumerableExpression diff --git a/src/core/Microsoft.Dynamic/Debugging/DebugInfoRewriter.cs b/src/core/Microsoft.Dynamic/Debugging/DebugInfoRewriter.cs index b660f509..081b5ce5 100644 --- a/src/core/Microsoft.Dynamic/Debugging/DebugInfoRewriter.cs +++ b/src/core/Microsoft.Dynamic/Debugging/DebugInfoRewriter.cs @@ -123,8 +123,8 @@ protected override Expression VisitLambda(MSAst.Expression node) { protected override Expression VisitExtension(Expression node) { // Lowered async nodes (AsyncExpression / AsyncEnumerableExpression) are opaque boundaries to the // debug transform, like nested lambdas (see VisitLambda). The async body is sliced into its own - // state machine, against its own generator label, when the node is reduced in its own context. - // TODO: make async bodies traceable via dedicated support here rather than skipping them. + // state machine, against its own generator label, when the node is reduced in its own context; + // a host traces the async body by handing the node its own debug hook (AsyncExpression.WithDebugInfo) instead. if (node is Microsoft.Scripting.Ast.AsyncExpression #if NET or Microsoft.Scripting.Ast.AsyncEnumerableExpression diff --git a/src/core/Microsoft.Dynamic/Debugging/TracePipeline/TracePipeline.cs b/src/core/Microsoft.Dynamic/Debugging/TracePipeline/TracePipeline.cs index 76df56de..ad55658a 100644 --- a/src/core/Microsoft.Dynamic/Debugging/TracePipeline/TracePipeline.cs +++ b/src/core/Microsoft.Dynamic/Debugging/TracePipeline/TracePipeline.cs @@ -60,6 +60,11 @@ public bool TrySetNextStatement(string sourceFile, SourceSpan sourceSpan) { if (sequencePointIndex == Int32.MaxValue) return false; + // Changing the location remaps the frame to a generator; a frame without a generator factory (a traced async body) cannot do that. + if (traceFrame.Generator is null && traceFrame.FunctionInfo.GeneratorFactory is null && + (sequencePointIndex != traceFrame.CurrentSequencePointIndex || traceFrame.ThrownException is not null)) + return false; + traceFrame.CurrentSequencePointIndex = sequencePointIndex; return true; } diff --git a/src/core/Microsoft.Dynamic/Debugging/TracingStateMachine.cs b/src/core/Microsoft.Dynamic/Debugging/TracingStateMachine.cs new file mode 100644 index 00000000..bfa3d8b4 --- /dev/null +++ b/src/core/Microsoft.Dynamic/Debugging/TracingStateMachine.cs @@ -0,0 +1,230 @@ +// 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; +using System.Collections.Generic; +using System.Diagnostics; +using System.Runtime.CompilerServices; +using System.Threading.Tasks; + +using Microsoft.Scripting.Debugging.CompilerServices; +using Microsoft.Scripting.Runtime; + +namespace Microsoft.Scripting.Debugging { + /// + /// Wraps the state machine of an async body compiled for tracing, and dispatches the body's trace events. + /// + /// + /// It sits between the body's state machine and its driver ( or + /// AsyncHelpers.DriveAsyncEnumerable), and is inserted only when the body is compiled for tracing + /// (see ), so the drivers know nothing about tracing. It consumes the + /// s that the instrumented body yields at its sequence points and passes everything else through. + /// + /// The body's frame lives exactly as long as the body runs, as for a generator in CPython: one is one + /// resumption, so the frame is pushed and call fired when the body resumes, and return fired and the frame popped + /// when the body suspends, completes, or faults. A suspended body is therefore never on a thread's frame stack, whatever runs in + /// the meantime and whichever thread the body resumes on. The one exception is a suspension that the driver resumes synchronously + /// (an await of an already completed task): then the frame stays open, since no other code runs in between and CPython does not + /// suspend there either. + /// + /// An exception raised by the trace callback when the body is about to resume is not thrown out of the driver. It is handed + /// to the body through the exception slot, the same way the driver delivers a faulted await, so it surfaces inside the body, where + /// the body's own try statements see it. + /// + /// The frame is read-only: it reports the body's location, but none of its locals. + /// + internal sealed class TracingStateMachine : IEnumerator { + // The entry sequence point is the location of the 'call' event, so it fires no 'line' event of its own (as in DebuggableLambdaBuilder). + private const int EntryCookie = 0; + + private readonly IEnumerator _states; + private readonly DebugContext _debugContext; + private readonly MarkerRuntimeVariables _variables; + private readonly StrongBox? _resultSlot; + private readonly StrongBox _exceptionSlot; + private readonly bool _isAsyncEnumerable; + + // The thread the frame is open on; null while the body is suspended. + private DefaultDebugThread? _thread; + + /// The state machine of the instrumented body. + /// The debug context the trace events are dispatched through. + /// The body's sequence points, indexed by the cookies of its s. + /// Where the body leaves its return value on completion, or null if it has none (an async generator). + /// The slot the body reads at each resume point, and rethrows from if it is set. + /// + /// Whether the driver is AsyncHelpers.DriveAsyncEnumerable, which hands every item but an to the + /// consumer, rather than , which awaits every item. + /// + internal TracingStateMachine(IEnumerator states, DebugContext debugContext, FunctionInfo functionInfo, + StrongBox? resultSlot, StrongBox exceptionSlot, bool isAsyncEnumerable) { + _states = states; + _debugContext = debugContext; + _variables = new MarkerRuntimeVariables(functionInfo); + _resultSlot = resultSlot; + _exceptionSlot = exceptionSlot; + _isAsyncEnumerable = isAsyncEnumerable; + } + + + public object? Current => _states.Current; + + object? IEnumerator.Current => Current; + + + public bool MoveNext() { + if (_thread is null) { + Open(); + } + + while (true) { + bool moved; + try { + moved = _states.MoveNext(); + } catch (Exception ex) { + // The 'exception' event proper has already been fired by the body's handlers (see OnExceptionCaught); + // like a synchronous debuggable body, the frame only reports here that it is being unwound. + try { + Dispatch(TraceEventKind.ExceptionUnwind, ex); + } finally { + Close(returnValue: null); + } + throw; + } + + if (!moved) { + Close(_resultSlot?.Value); + return false; + } + + object? item = _states.Current; + if (item is not DebugPoint debugPoint) { + if (!ResumesSynchronously(item)) { + Close(item is AwaitPoint awaitPoint ? awaitPoint.Task : item); + } + return true; + } + + _variables.Marker = debugPoint.Cookie; + if (debugPoint.Cookie != EntryCookie) { + // This resumes the body past a debug point, so, like the driver resuming it past an await, it always writes the slot. + _exceptionSlot.Value = TryDispatch(TraceEventKind.TracePoint); + } + } + } + + + public void Reset() { + throw new NotSupportedException(); + } + + + public void Dispose() { + _states.Dispose(); + } + + + /// + /// Fires the exception event for an exception the body is about to handle. + /// + /// + /// Called by the instrumented body on entering each exception handler, the way a synchronous debuggable body reports + /// first-chance exceptions. An IronPython function body handles every exception at least once (to update its traceback), so this + /// also reports an exception that eventually propagates out of the body. + /// + internal void OnExceptionCaught(Exception exception) { + Debug.Assert(_thread is not null, "the body only runs while its frame is open"); + Dispatch(TraceEventKind.Exception, exception); + } + + + private void Open() { + _thread = (DefaultDebugThread)_debugContext.GetCurrentThread(); + _thread.LiftVariables(_variables); + + // At the very first resumption the marker is the entry point, and the body yields it before reading the slot; + // at later resumptions the body is resuming past a suspension and reads the slot immediately. + if (TryDispatch(TraceEventKind.FrameEnter) is { } exception) { + _exceptionSlot.Value = exception; + } + } + + + private void Close(object? returnValue) { + try { + Dispatch(TraceEventKind.FrameExit, returnValue); + } finally { + _thread!.PopFrame(); + _thread = null; + } + } + + + /// + /// Whether the driver resumes the body right away after , with no other code running in between. + /// + private bool ResumesSynchronously(object? item) { + return _isAsyncEnumerable + ? item is AwaitPoint { Task.IsCompleted: true } // anything else is a value handed over to the consumer + : item is not Task { IsCompleted: false }; // DriveAsync forwards a non-Task value straight back + } + + + private void Dispatch(TraceEventKind kind, object? payload = null) { + _debugContext.DispatchDebugEvent(_thread!, _variables.Marker, kind, payload); + } + + + private Exception? TryDispatch(TraceEventKind kind) { + try { + Dispatch(kind); + return null; + } catch (Exception ex) { + return ex; + } + } + + + /// + /// The runtime variables of the body's frame: the body's and its current sequence point. + /// + /// + /// Slot 0 is the and slot 1 the marker, which is the layout expects. + /// No further slots are exposed, so the frame reports no locals. + /// + private sealed class MarkerRuntimeVariables : IRuntimeVariables { + private readonly FunctionInfo _functionInfo; + + internal MarkerRuntimeVariables(FunctionInfo functionInfo) { + _functionInfo = functionInfo; + } + + internal int Marker { get; set; } + + public int Count => 2; + + public object? this[int index] { + get => index == 0 ? _functionInfo : Marker; + set { + if (index == 1) Marker = (int)value!; + } + } + } + } + + + /// + /// Marker that an async body compiled for tracing yields at each of its sequence points. + /// + /// + /// It is consumed by , so the driver never sees it. indexes the sequence points + /// of the body's . + /// + internal sealed class DebugPoint(int cookie) { + public int Cookie { get; } = cookie; + } +}