Table of Contents

CHT013: async method returns an AsyncReentrantLock releaser

Cause

An async method, local function, lambda or anonymous method returns Task<AsyncReentrantLock.Releaser> or ValueTask<AsyncReentrantLock.Releaser>.

Rule Description

AsyncReentrantLock records each hold in an AsyncLocal, so a nested call on the same flow can see that it already holds the lock. An async method runs in its own copy of the ExecutionContext, and any AsyncLocal value it sets is discarded when it returns. A method that awaits LockAsync and returns the releaser therefore hands its caller a real hold that the lock does not attribute to the caller.

The caller holds the lock, but the lock sees it as an outside caller. Its next nested LockAsync, directly or through anything it calls, waits for the lock it already holds, and never returns.

The rule fires whatever else the method does. Logging, validation or a retry loop around the await doesn't change where the AsyncLocal write lands. It does not fire when the method acquires only through LockLeafAsync, which records no hold to lose.

How to Fix

Remove async and return the ValueTask from LockAsync. The AsyncLocal write then happens in the caller's context. When the method does nothing else, CHT011 fires on it as well, and its code fix makes exactly this change.

// Before
private async ValueTask<AsyncReentrantLock.Releaser> EnterAsync()
    => await _lock.LockAsync().ConfigureAwait(false);

// After
private ValueTask<AsyncReentrantLock.Releaser> EnterAsync()
    => _lock.LockAsync();

Work that has to run before the acquisition can stay, as long as the method itself is not async:

private ValueTask<AsyncReentrantLock.Releaser> EnterAsync(CancellationToken ct)
{
    _log.Entering();
    return _lock.LockAsync(ct);
}

Option 2: Keep the hold inside the method

If the method has to await something before it can acquire, take the lock where the work happens and don't hand the releaser out:

private async Task UpdateAsync(CancellationToken ct)
{
    await PrepareAsync(ct).ConfigureAwait(false);
    using (await _lock.LockAsync(ct).ConfigureAwait(false))
    {
        // ...
    }
}

When to Suppress

Suppress only when the caller never nests into the lock and the wrapper exists for another reason, for example a test that pins this behaviour.

#pragma warning disable CHT013 // Caller never re-enters; the hold is released before any nested call

Example

Violating Code

public sealed class Inventory
{
    private readonly AsyncReentrantLock _lock = new();

    // CHT013: the hold does not reach the caller
    private async ValueTask<AsyncReentrantLock.Releaser> EnterAsync(CancellationToken ct)
    {
        await _audit.WriteAsync("enter", ct).ConfigureAwait(false);
        return await _lock.LockAsync(ct).ConfigureAwait(false);
    }

    public async Task AddAsync(Item item, CancellationToken ct)
    {
        using (await EnterAsync(ct).ConfigureAwait(false))
        {
            _items.Add(item);
            await RebalanceAsync(ct).ConfigureAwait(false);   // deadlocks: takes _lock again
        }
    }
}

Fixed Code

public sealed class Inventory
{
    private readonly AsyncReentrantLock _lock = new();

    public async Task AddAsync(Item item, CancellationToken ct)
    {
        await _audit.WriteAsync("enter", ct).ConfigureAwait(false);
        using (await _lock.LockAsync(ct).ConfigureAwait(false))
        {
            _items.Add(item);
            await RebalanceAsync(ct).ConfigureAwait(false);   // nests: no deadlock
        }
    }
}

See Also