Skip to content

JIT: (bug) Runtime-async version of a Synchronized method releases the monitor twice, replacing the real exception and corrupting the caller's lock #133865

Closed

Description

@EgorBo

When a [MethodImpl(MethodImplOptions.Synchronized)] method returning an awaitable is awaited by a runtime-async caller and the awaitable is already faulted, the JIT-generated async version calls Monitor.SynchronizedMethodExit twice.

Minimal Repro

Requires <Features>$(Features);runtime-async=on</Features> in the project file.

using System;
using System.Runtime.CompilerServices;
using System.Threading;
using System.Threading.Tasks;

public class Program
{
    [MethodImpl(MethodImplOptions.Synchronized)]
    public Task Ret(Task t) => t;

    static async Task Caller(Program p, Task t)
    {
        await p.Ret(t);
    }

    public static int Main()
    {
        Program p = new Program();
        Task faulted = Task.FromException(new InvalidOperationException("boom"));
        try
        {
            Caller(p, faulted).GetAwaiter().GetResult();
        }
        catch (Exception e)
        {
            Console.WriteLine("caught: " + e.GetType().Name + ": " + e.Message);
        }

        // Second symptom: the extra release drops a lock the caller owns.
        Program p2 = new Program();
        Task faulted2 = Task.FromException(new InvalidOperationException("boom2"));
        lock (p2)
        {
            try
            {
                Caller(p2, faulted2).GetAwaiter().GetResult();
            }
            catch (Exception e)
            {
                Console.WriteLine("caught2: " + e.GetType().Name + ": " + e.Message);
            }
            Console.WriteLine("caller still holds its own lock: " + Monitor.IsEntered(p2));
        }
        return 100;
    }
}

Expected

caught: InvalidOperationException: boom
caught2: InvalidOperationException: boom2
caller still holds its own lock: True

(exit code 100 — this is what the same source compiled without runtime-async=on prints)

Actual

caught: SynchronizationLockException: The calling thread does not hold the lock.
caught2: InvalidOperationException: boom2
caller still holds its own lock: False
Unhandled exception. System.Threading.SynchronizationLockException: The calling thread does not hold the lock.
   at System.Threading.Lock.Exit()
   at System.Threading.Monitor.Exit(Object obj)
   at Program.Main()

Notes

  • Compiler::fgAddSyncMethodEnterExit (src/coreclr/jit/flowgraph.cpp) wraps the whole imported body — including the TransparentAwait the importer inserts after its own MON_EXIT — inside the fault region, so a synchronously faulted awaitable runs the fault handler and exits the monitor a second time.
  • Symptom 1: the real exception is replaced by SynchronizationLockException.
  • Symptom 2 (silent lock corruption): when the caller holds the same monitor recursively, the extra release drops the caller's acquisition, so another thread can enter the still-"locked" critical section.
  • Also reproduces on the shipped 11.0.0-rc.1 Release runtime; N/A for .NET 10 (runtime-async is main-only).

Activity

  1. added this to the 12.0.0 milestone on Sep 14, 2026
  2. dotnet-policy-service commented on Sep 14, 2026

    @dotnet-policy-service
    Contributor

    Tagging subscribers to this area: @JulieLeeMSFT, @jakobbotsch
    See info in area-owners.md if you want to be subscribed.

  3. locked and limited conversation to collaborators on Oct 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

area-CodeGen-coreclrCLR JIT compiler in src/coreclr/src/jit and related components such as SuperPMIruntime-async

Type

No type

Projects

No projects

    Milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions