Skip to content

Trace async bodies with a debug hook - #369

Merged
BCSharp merged 5 commits into
IronLanguages:mainfrom
BCSharp:async-trace
Oct 21, 2026
Merged

BCSharp merged 5 commits into
IronLanguages:mainfrom
BCSharp:async-trace

Conversation

@BCSharp

@BCSharp BCSharp commented Oct 20, 2026 •

Copy link
Copy Markdown
Member

This is a continuation of #363, which left tracing unresolved.

In the approach in this PR, an AsyncExpression / AsyncEnumerableExpression can carry a debug hook (WithDebugInfo). When it does, its reduction instruments the body (a DebugPoint yield at each sequence point, an exception report at the start of each catch block) and wraps the body's state machine in a TracingStateMachine, which dispatches call/line/return/exception events through the DebugContext. The drivers in AsyncHelpers are unchanged.

I have the corresponding change to IronPython that makes use of this mechanism.

A side change: TracePipeline.TrySetNextStatement returns false for a frame without a generator factory instead of failing with a NRE.

@slozier slozier left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is outside of my comfort zone so I'll defer to you. What I did check seemed fine. Presumably I'd need the IronPython side of it to try and break things?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Unresolved tracing and lowering defects can swallow exceptions, misexecute handlers, reject valid bodies, or dereference a missing generator factory.

5 open issues
What changed in this PR

Adds opt-in tracing for async bodies, extending the support left unresolved by #363 without changing the async drivers.

Changes:

  • Adds debug hooks and sequence-point instrumentation to async expressions.
  • Wraps state machines to dispatch tracing events.
  • Guards statement relocation for frames without generator factories.
File Description
src/​core/​Microsoft.Dynamic/​Debugging/​TracingStateMachine.cs Dispatches async trace events and manages frame lifetime.
src/​core/​Microsoft.Dynamic/​Debugging/​TracePipeline/​TracePipeline.cs Guards unsupported statement relocation.
src/​core/​Microsoft.Dynamic/​Debugging/​DebugInfoRewriter.cs Updates comments describing async tracing.
src/​core/​Microsoft.Dynamic/​Ast/​GeneratorRewriter.cs Updates comments describing async boundaries.
src/​core/​Microsoft.Dynamic/​Ast/​AsyncRewriter.cs Integrates tracing and shares exception-slot checks.
src/​core/​Microsoft.Dynamic/​Ast/​AsyncExpression.cs Adds and preserves debug hooks.
src/​core/​Microsoft.Dynamic/​Ast/​AsyncEnumerableRewriter.cs Integrates async-enumerable tracing.
src/​core/​Microsoft.Dynamic/​Ast/​AsyncEnumerableExpression.cs Adds and preserves debug hooks.
src/​core/​Microsoft.Dynamic/​Ast/​AsyncDebugSupport.cs Instruments sequence points and exception handlers.

Review effort: Balanced


return Expression.Block(
typeof(void),
Utils.YieldReturn(_yieldLabel, Expression.Constant(new DebugPoint(cookie))),

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The observation is correct but not applicable for this PR. Async tracing deliberately behaves like the DLR's sync tracing here, and this limitation is DLR-wide:

  • Sync tracing has it too. DebugContext.TransformLambda turns every sequence point into a yield in its debug generator, and GeneratorRewriter doesn't allow yields in catch filters or fault blocks. So a lambda with a sequence point inside a filter or fault block already fails to transform for sync tracing (yield in filter is not allowed / yield in fault block is not supported). Filters without sequence points trace fine.
  • The DLR interpreter doesn't support catch filters at all. LightCompiler throws NRE for any filter, traced or not. So a language that compiles its exception tests into .NET filters is already limited to compiled code.
  • IronPython isn't affected. It emits neither filters nor fault blocks: except clauses are tested inside a plain catch (Exception) body. IronPython has no filters to step through. A Python except clause is compiled as ordinary code inside a plain catch (Exception) block: an if/else chain that calls CheckException with each clause's expression in turn, and rethrows if none matches. So everything Python considers part of the except test runs in the catch body, where sequence points are allowed (in async bodies, debug-point yields there are supported, through the deferred-handler lowering). Perhaps other languages do something similar to sidestep the DLR limitation.

If sequence points in filters and fault blocks need supporting (e.g. for a language with C#-like catch … when), it should be done for sync and async tracing together, for example by emitting no debug point there, or a non-yielding one.

}

protected override Expression VisitTry(TryExpression node) {
var visited = (TryExpression)base.VisitTry(node);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in 7ece84a: The variables are now reset on each entry into the try. A resumption into the handler is routed past the reset, so the exception still survives the handler's own yields.

This change to the DLR by itself does not affect IronPython. IronPython has its own copy of GeneratorRewriter so the change goes there too.

Comment on lines +196 to +197
// A nested async node is sliced into its own state machine against its own label; leave it opaque.
if (node is AsyncExpression

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, though IronPython never builds inline DLR generators. Accepted for correctes (in 87ea5cb), though no test coverage.

) {
return node;
}
return base.VisitExtension(node);

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confirmed: when the trace function raises on the call event as an async generator resumes after a yield, CPython raises it at the yield and stops. IronPython swallows it and continues.

The cause: the wrapper parks the exception in the exception slot, but a plain yield has no "rethrow if pending" check after it. The next debug point overwrites the slot. In practice this hits pdb, whose q makes the next call event raise BdbQuit.

Adding the check after yields on the body's own label is safe. The driver always clears the slot before resuming past a yield, so the check can only see the wrapper's exception.

I am accepting this fix in a separate commit (8068b3e), because for some reason after I accepted the suggestion from comment 5 (in TracePipeline.cs), suddenly all proposed autofix suggestions by Copilot turned as "outdated".

IPY will carry a new test that covers this issue.

Comment thread src/core/Microsoft.Dynamic/Debugging/TracePipeline/TracePipeline.cs Outdated
@slozier

slozier commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

@BCSharp feel free to ignore AI review if not relevant, just trying it out.

This issue has no relevance for IronPython, but it prevents an NRE in other language hosts that call `TrySetNextStatement` directly.

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
@BCSharp

BCSharp commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

@BCSharp feel free to ignore AI review if not relevant, just trying it out.

Well, it was useful. Of the five problem areas "only" two can be triggered by IronPython, but the DLR should work with any dynamic language, so all five need fixing. I will add some tests to IronPython to catch those two visible cases; they will land with the IPY PR. One of them fails on the "old" async implementation in IPY as well; I will think about it when I get there.

This PR is an N-th iteration for this problem I tried and it changed substantially in the process. I see there are still some sharp corners, but the Copilot did a good job of raising the issues. This is the old challenge: tracing using yields can put them in some difficult places: in handlers, in finally blocks, in with bodies, and so on. We already have an old and still unfixed bug on continue in a finally block (IronLanguages/ironpython3#1182).

@slozier

slozier commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

We already have an old and still unfixed bug on continue in a finally block (IronLanguages/ironpython3#1182).

Yes, this one is annoying and has caught me multiple times. Definitely something I want to look into again...

@BCSharp
BCSharp marked this pull request as draft October 21, 2026 04:40
@BCSharp

BCSharp commented Oct 21, 2026

Copy link
Copy Markdown
Member Author

We already have an old and still unfixed bug on continue in a finally block (IronLanguages/ironpython3#1182).

Yes, this one is annoying and has caught me multiple times. Definitely something I want to look into again...

Indeed, it is also on my TODO list (already for a long time 😃 ). I consider it important and it is finally getting close to the top.

@BCSharp
BCSharp marked this pull request as ready for review October 21, 2026 19:24
@BCSharp
BCSharp merged commit 922753f into IronLanguages:main Oct 21, 2026
8 checks passed
@BCSharp
BCSharp deleted the async-trace branch October 21, 2026 23:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants