Skip to content

Aspire run-session server: SendMessageAsync over-releases _webSocketAccess on cancellation, permitting concurrent WebSocket sends #55546

Description

@lifengl

Summary

In Aspire.Tools.Service.AspireServerService.SendMessageAsync (src/Dotnet.Watch/AspireService/AspireServerService.cs), the _webSocketAccess semaphore is released in a finally block even when WaitAsync throws before the semaphore was acquired. Because _webSocketAccess is created as new SemaphoreSlim(1) (so maxCount == int.MaxValue), the stray Release() raises the permit count above 1. A later send then acquires the "extra" permit while another send is still in flight, producing two concurrent WebSocket.SendAsync calls on the same socket — which is unsupported and throws InvalidOperationException ("There is already one outstanding 'SendAsync' call for this WebSocket instance") or corrupts the frame stream.

Code

SendMessageAsync:

private readonly SemaphoreSlim _webSocketAccess = new(1); // maxCount == int.MaxValue

...

var success = false;
try
{
    using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _shutdownCancellationSource.Token, connection.HttpRequestAborted);

    await _webSocketAccess.WaitAsync(cancelTokenSource.Token); // can throw before acquiring
    await connection.Socket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token);

    success = true;
}
finally
{
    if (!success)
    {
        _socketConnectionManager.RemoveSocketConnection(connection);
    }

    _webSocketAccess.Release(); // runs even when WaitAsync threw before acquiring -> over-release
}

Why it is a bug

If cancelTokenSource is canceled (server shutdown, connection.HttpRequestAborted, or the caller's cancellationToken) while WaitAsync is still waiting, WaitAsync throws OperationCanceledException without having decremented the semaphore. Control passes to the finally, which calls Release() and increments the count past 1. Since the semaphore is constructed with the single-argument SemaphoreSlim(1) constructor, maxCount is int.MaxValue, so this does not throw SemaphoreFullException — it silently over-releases. From that point the semaphore no longer enforces single-writer access, so a subsequent notification can send while another send is still in progress.

This is reachable on the normal Ctrl+C / shutdown / client-disconnect paths (the same cancellation paths involved in #55229).

Suggested fix

Only release when the wait actually succeeded:

var success = false;
var acquired = false;
try
{
    using var cancelTokenSource = CancellationTokenSource.CreateLinkedTokenSource(
        cancellationToken, _shutdownCancellationSource.Token, connection.HttpRequestAborted);

    await _webSocketAccess.WaitAsync(cancelTokenSource.Token);
    acquired = true;
    await connection.Socket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, endOfMessage: true, cancelTokenSource.Token);

    success = true;
}
finally
{
    if (!success)
    {
        _socketConnectionManager.RemoveSocketConnection(connection);
    }

    if (acquired)
    {
        _webSocketAccess.Release();
    }
}

Notes

Found by code review while investigating an unrelated Visual Studio hang, so I don't have a deterministic repro, but the defect is clear from the source. The Aspire.Tools.Service package is consumed by Visual Studio and C# Dev Kit in addition to dotnet watch, so the same code path runs in all three hosts.

Metadata

Metadata

Assignees

Labels

Area-Watchdotnet-watchcookieIssue Monster Loves Cookies!

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions