roslyn icon indicating copy to clipboard operation
roslyn copied to clipboard

Exception Handler support

Open 333fred opened this issue 6 months ago • 2 comments

Our current approach to rewriting await expressions in finally blocks is technically unsound: the rewrite can result in a new exit block from the original method being created. As a simple example, here is an original method, and what it gets rewritten to by AsyncExceptionHandlerRewriter (slightly simplified):

static async Task<int> M()
{
    int x = 0;

    try
    {
        x = await F();
        return x;
    }
    finally
    {
        x += await F();
    }
}
async Task<int> M()
{
    int x = 0;
    object ex = null;
    int retTaken = 0;
    int retTemp = 0;
    try
    {
        x = await F();
        goto @finally;
    }
    catch (object o)
    {
        ex = o;
        goto @finally;
    }

@finally:
    x += await F();
    if (ex != null)
    {
        if (ex is Exception e) ExceptionDispatchInfo.Capture(e).Throw();
        throw ex;
    }

    switch(retTaken)
    {
        case 2:
            return retTemp;
        default:
            break; // This is the problem
    }
}

While we can statically walk the method and prove via flow analysis that the default case of the switch isn't reachable, the generalized version of this problem is the halting problem. To date, this has never been a problem because the code that the async rewriter produces is then wrapped and rewritten again into the async state machine, and the missing return becomes completely unobservable. However, for runtime async, we're going to be emitting this general structure, essentially unchanged: that break;, then, becomes a branch to an invalid IL location and the method ends without a ret. We're taking a conservative approach to fixing this by appending a throw null; at the end of every method that has an await in a finally. Most of the time, this is provably never reachable, and the basic block reducer will eliminate it. However, sometimes a standard control flow analysis will not be able to prove that the throw null; is not reachable, and it will remain in the final IL. It is not reachable, but its presence will ensure that the method is always valid.

async Task<int> M()
{
    int x = 0;
    object ex = null;
    int retTaken = 0;
    int retTemp = 0;
    try
    {
        x = await F();
        goto @finally;
    }
    catch (object o)
    {
        ex = o;
        goto @finally;
    }

@finally:
    x += await F();
    if (ex != null)
    {
        if (ex is Exception e) ExceptionDispatchInfo.Capture(e).Throw();
        throw ex;
    }

    switch(retTaken)
    {
        case 2:
            return retTemp;
        default:
            break;
    }

    throw null; // Unreachable, but not easily provable statically
}

Relates to test plan https://github.com/dotnet/roslyn/issues/75960

333fred avatar May 30 '25 20:05 333fred

It is expected that this does not yet compile. I wanted to get feedback on the approach before taking more time to go through and handle all the cases.

333fred avatar May 30 '25 20:05 333fred

@jcouv @RikkiGibson this is ready for review. I'll likely have another commit sometime tomorrow that also adds runtime async verification for await foreach tests, but I don't anticipate that to change any of the core implementation changes so I think you should be good to proceed with the initial set.

333fred avatar Jun 18 '25 03:06 333fred

            // (3,1): hidden CS8019: Unnecessary using directive.

Test AsyncWithEH, line 150: Do we understand what API we used for async that we no longer use for runtime-async?


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncEHTests.cs:150 in 92399e0. [](commit_id = 92399e001e52507695a8b81f899fd0e36d229c1d, deletion_comment = False)

jcouv avatar Jun 24 '25 20:06 jcouv

                    [G]: Unexpected type on the stack. { Offset = 0x104, Found = Int32, Expected = ref 'System.Threading.Tasks.Task`1<int32>' }

Test AsyncWithEHCodeQuality, line 587: Did we file an issue on ILVerify already? It'd be good to link here and other similar places


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncEHTests.cs:587 in 92399e0. [](commit_id = 92399e001e52507695a8b81f899fd0e36d229c1d, deletion_comment = False)

jcouv avatar Jun 24 '25 20:06 jcouv

        verifier.VerifyDiagnostics(

nit: Test AsyncWithException1, line 690: Consider also verifying diagnostics on the async baseline above, and possibly removing the async from F Also applies to some other tests below (AsyncWithException2 and many more)


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncEHTests.cs:690 in 503c056. [](commit_id = 503c056e02676eb51d64123af23703fee964e6e4, deletion_comment = False)

jcouv avatar Jun 24 '25 21:06 jcouv

                IL_0145:  ldnull

nit: Thanks for pointing out this test. Consider leaving a comment for what to look for in the IL (this is an async test where the new "throw null" is survives and is observed). Or maybe in AsyncInFinally003 since it already had an IL baseline #Closed


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncEHTests.cs:1749 in 503c056. [](commit_id = 503c056e02676eb51d64123af23703fee964e6e4, deletion_comment = False)

jcouv avatar Jun 24 '25 22:06 jcouv

        verifier.VerifyIL("Test.G(System.Threading.SemaphoreSlim)", """

Test AsyncInFinally006_AsyncVoid_01, line 2059 (last VerifyIL): Was there anything in particular to observe here? This should not have been affected by the change in this PR, right?


Refers to: src/Compilers/CSharp/Test/Emit/CodeGen/CodeGenAsyncEHTests.cs:2059 in 503c056. [](commit_id = 503c056e02676eb51d64123af23703fee964e6e4, deletion_comment = False)

jcouv avatar Jun 24 '25 22:06 jcouv

Was there anything in particular to observe here? This should not have been affected by the change in this PR, right?

Correct, I'm verifying that fact.

333fred avatar Jun 24 '25 22:06 333fred

Do we understand what API we used for async that we no longer use for runtime-async?

None. The non-runtime async code does not verify diagnostics, this is present on both.

333fred avatar Jun 24 '25 22:06 333fred

nit: Thanks for pointing out this test. Consider leaving a comment for what to look for in the IL (this is an async test where the new "throw null" is survives and is observed). Or maybe in AsyncInFinally003 since it already had an IL baseline

I'm not sure that comment would be especially useful after this review?

333fred avatar Jun 24 '25 22:06 333fred

nit: Test AsyncWithException1, line 690: Consider also verifying diagnostics on the async baseline above, and possibly removing the async from F

For the former; to be honest, I don't think the effort is worth it here. For the latter, that would potentially change the behavior of the test unless I did more extensive rewrites to change to Task.FromException, which I also don't think is worth it.

333fred avatar Jun 24 '25 22:06 333fred