Skip to content

AsyncMethodBuilder.Start called with a different this reference than AsyncMethodBuilder.Task #41222

@madelson

Description

@madelson

Version Used:
Visual Studio 16.4.2

Steps to Reproduce:

I came across this when trying to leverage the capability to build custom async method return types.

I wanted to build a type similar to Task, but which would be resiliant to StackOverflow. To do this, I wanted to do more inside the methodbuilder's Start() method rather than just always calling stateMachine.MoveNext(). Here's a minimal example which reproduces the behavior I found.

class Program
{
    public static void Main(string[] args)
    {
        Foo(10).GetAwaiter().GetResult(); // throws "no state set"
            
        async MyTask<string> Foo(int n) =>
                n <= 0 ? "bar" : await Foo(n - 1);
    }
}

[AsyncMethodBuilder(typeof(MyTaskMethodBuilder<>))]
    public readonly struct MyTask<TResult> : INotifyCompletion
    {
        internal MyTask(object state) { }

        public bool IsCompleted => false;

        public MyTask<TResult> GetAwaiter() => this;

        public TResult GetResult() => throw new NotImplementedException();

        public void OnCompleted(Action continuation) => throw new NotImplementedException();
    }

    public struct MyTaskMethodBuilder<TResult>
    {
        private object _state;

        public static MyTaskMethodBuilder<TResult> Create() => default;

        public void Start<TStateMachine>(ref TStateMachine stateMachine)
            where TStateMachine : IAsyncStateMachine
        {
            // doesn't stick because a copy of the builder is used for accessing the Task property
            this._state = new State<TStateMachine>();
        }

        public void SetStateMachine(IAsyncStateMachine stateMachine) { }

        public void SetException(Exception exception) => throw new NotImplementedException();

        public void SetResult(TResult result) => throw new NotImplementedException();

        public void AwaitOnCompleted<TAwaiter, TStateMachine>(
            ref TAwaiter awaiter,
            ref TStateMachine stateMachine)
            where TAwaiter : INotifyCompletion
            where TStateMachine : IAsyncStateMachine => throw new NotImplementedException();

        public void AwaitUnsafeOnCompleted<TAwaiter, TStateMachine>(
            ref TAwaiter awaiter,
            ref TStateMachine stateMachine)
            where TAwaiter : ICriticalNotifyCompletion
            where TStateMachine : IAsyncStateMachine => throw new NotImplementedException();

        public MyTask<TResult> Task => new MyTask<TResult>(this._state ?? throw new InvalidOperationException("no state set"));

        internal object ObjectIdForDebugger => this._state ??= new State<IAsyncStateMachine>();

        private class State<TStateMachine>
            where TStateMachine : IAsyncStateMachine
        {
        }
    }

Expected Behavior:
With the builder being a mutable struct, I would expect that the generated async code would ensure that each call made into the builder is done in such a way that ensures that the builder state can flow between calls. This works between AwaitOnCompleted and Task, for example, but not between Start and Task.

Actual Behavior:
Setting instance fields of the builder in Start() does not carry over to Task.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions